@pentoshi/clai 4.11.2 → 4.11.3

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 (91) 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/llm/token-estimate-calibration.d.ts +0 -1
  11. package/dist/llm/token-estimate-calibration.js +3 -3
  12. package/dist/llm/token-estimate-calibration.js.map +1 -1
  13. package/dist/llm/tool-protocol.d.ts +1 -0
  14. package/dist/llm/tool-protocol.js +29 -7
  15. package/dist/llm/tool-protocol.js.map +1 -1
  16. package/dist/prompts/embedded.js +1 -1
  17. package/dist/prompts/embedded.js.map +1 -1
  18. package/dist/prompts/system.agent.md +5 -5
  19. package/dist/session-runtime/binding.d.ts +1 -1
  20. package/dist/session-runtime/binding.js +3 -1
  21. package/dist/session-runtime/binding.js.map +1 -1
  22. package/dist/session-runtime/child-bridge.d.ts +7 -2
  23. package/dist/session-runtime/child-bridge.js +55 -4
  24. package/dist/session-runtime/child-bridge.js.map +1 -1
  25. package/dist/session-runtime/client.d.ts +5 -0
  26. package/dist/session-runtime/client.js +13 -5
  27. package/dist/session-runtime/client.js.map +1 -1
  28. package/dist/session-runtime/host.d.ts +7 -0
  29. package/dist/session-runtime/host.js +112 -19
  30. package/dist/session-runtime/host.js.map +1 -1
  31. package/dist/session-runtime/types.d.ts +10 -0
  32. package/dist/tools/fs.d.ts +0 -5
  33. package/dist/tools/fs.js +318 -272
  34. package/dist/tools/fs.js.map +1 -1
  35. package/dist/tools/shell.d.ts +5 -2
  36. package/dist/tools/shell.js +313 -20
  37. package/dist/tools/shell.js.map +1 -1
  38. package/dist/tui-v2/app/App.js +72 -55
  39. package/dist/tui-v2/app/App.js.map +1 -1
  40. package/dist/tui-v2/app/layout-widths.d.ts +21 -0
  41. package/dist/tui-v2/app/layout-widths.js +50 -0
  42. package/dist/tui-v2/app/layout-widths.js.map +1 -0
  43. package/dist/tui-v2/bootstrap/resize-repaint.d.ts +8 -1
  44. package/dist/tui-v2/bootstrap/resize-repaint.js +8 -0
  45. package/dist/tui-v2/bootstrap/resize-repaint.js.map +1 -1
  46. package/dist/tui-v2/bootstrap/start-tui-v2.js +22 -7
  47. package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
  48. package/dist/tui-v2/components/modal/secret-modal.js +10 -10
  49. package/dist/tui-v2/components/modal/secret-modal.js.map +1 -1
  50. package/dist/tui-v2/components/pager/pager.js +6 -1
  51. package/dist/tui-v2/components/pager/pager.js.map +1 -1
  52. package/dist/tui-v2/components/transcript/assistant-message.d.ts +2 -0
  53. package/dist/tui-v2/components/transcript/assistant-message.js +4 -2
  54. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  55. package/dist/tui-v2/components/transcript/compacted-row.js +4 -1
  56. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  57. package/dist/tui-v2/components/transcript/tool-card.js +4 -1
  58. package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
  59. package/dist/tui-v2/components/transcript/transcript-row.js +1 -1
  60. package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
  61. package/dist/tui-v2/input/chord-from-opentui-key.d.ts +6 -0
  62. package/dist/tui-v2/input/chord-from-opentui-key.js +32 -6
  63. package/dist/tui-v2/input/chord-from-opentui-key.js.map +1 -1
  64. package/dist/tui-v2/input/escape-cancellation.d.ts +7 -0
  65. package/dist/tui-v2/input/escape-cancellation.js +11 -0
  66. package/dist/tui-v2/input/escape-cancellation.js.map +1 -0
  67. package/dist/tui-v2/rendering/pager-markdown.d.ts +4 -0
  68. package/dist/tui-v2/rendering/pager-markdown.js +2 -0
  69. package/dist/tui-v2/rendering/pager-markdown.js.map +1 -1
  70. package/dist/ui-core/actions/chord.d.ts +4 -0
  71. package/dist/ui-core/actions/chord.js.map +1 -1
  72. package/dist/ui-core/bootstrap/capabilities.d.ts +6 -0
  73. package/dist/ui-core/bootstrap/capabilities.js +39 -4
  74. package/dist/ui-core/bootstrap/capabilities.js.map +1 -1
  75. package/dist/ui-core/react/use-session-state.d.ts +9 -2
  76. package/dist/ui-core/react/use-session-state.js +35 -4
  77. package/dist/ui-core/react/use-session-state.js.map +1 -1
  78. package/dist/ui-core/rendering/code-block.d.ts +10 -5
  79. package/dist/ui-core/rendering/code-block.js +68 -40
  80. package/dist/ui-core/rendering/code-block.js.map +1 -1
  81. package/dist/ui-core/rendering/markdown.d.ts +6 -3
  82. package/dist/ui-core/rendering/markdown.js +92 -71
  83. package/dist/ui-core/rendering/markdown.js.map +1 -1
  84. package/dist/ui-core/rendering/render-markdown-lines.d.ts +4 -0
  85. package/dist/ui-core/rendering/render-markdown-lines.js +4 -1
  86. package/dist/ui-core/rendering/render-markdown-lines.js.map +1 -1
  87. package/dist/ui-core/rendering/streaming-markdown.js +14 -1
  88. package/dist/ui-core/rendering/streaming-markdown.js.map +1 -1
  89. package/dist/version.generated.d.ts +2 -2
  90. package/dist/version.generated.js +2 -2
  91. 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,48 @@ 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 appendPreconditionFailure(path, expected, actual) {
50
+ return {
51
+ ok: false,
52
+ output: `fs.append integrity check failed for ${path}: expected prior bytes=${expected}, actual=${actual}. ` +
53
+ `The file already advanced past your expectation. Re-send only the content that is not on disk yet with expectedPriorBytes=${actual}.`,
54
+ exitCode: 1,
55
+ };
56
+ }
57
+ function retryableFsError(error) {
58
+ const code = error && typeof error === "object" && "code" in error
59
+ ? String(error.code ?? "")
60
+ : "";
61
+ return RETRYABLE_FS_CODES.has(code);
62
+ }
63
+ async function writeFileAtomicAttempt(path, contents) {
64
+ const resolved = mutationTarget(path);
24
65
  const priorMode = await stat(resolved)
25
66
  .then((st) => st.mode & 0o7777)
26
67
  .catch(() => undefined);
@@ -36,9 +77,8 @@ export async function writeFileAtomic(resolved, contents) {
36
77
  await handle.sync();
37
78
  await handle.close();
38
79
  handle = undefined;
39
- if (priorMode !== undefined) {
80
+ if (priorMode !== undefined)
40
81
  await chmod(tempPath, priorMode);
41
- }
42
82
  await rename(tempPath, resolved);
43
83
  }
44
84
  catch (error) {
@@ -48,6 +88,19 @@ export async function writeFileAtomic(resolved, contents) {
48
88
  throw error;
49
89
  }
50
90
  }
91
+ export async function writeFileAtomic(resolved, contents) {
92
+ for (let attempt = 0;; attempt += 1) {
93
+ try {
94
+ await writeFileAtomicAttempt(resolved, contents);
95
+ return;
96
+ }
97
+ catch (error) {
98
+ if (attempt >= 3 || !retryableFsError(error))
99
+ throw error;
100
+ await new Promise((resolve) => setTimeout(resolve, 15 * 2 ** attempt));
101
+ }
102
+ }
103
+ }
51
104
  /**
52
105
  * Threshold above which whole-file mutation is refused or replaced by an
53
106
  * in-place strategy. Reading and rewriting a very large file holds 2x its size
@@ -648,34 +701,32 @@ export async function fsRead(path, options = {}) {
648
701
  }
649
702
  export async function fsWrite(path, content, options = {}) {
650
703
  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",
704
+ return withFileMutation(resolved, async () => {
705
+ let before = "";
706
+ let existed = false;
707
+ try {
708
+ before = await readFile(resolved, "utf8");
709
+ existed = true;
710
+ }
711
+ catch (error) {
712
+ if (error?.code !== "ENOENT")
713
+ throw error;
714
+ }
715
+ await mkdir(dirname(resolved), { recursive: true });
716
+ await writeFileAtomic(resolved, content);
717
+ const change = buildFileChange({
718
+ path: resolved,
719
+ before,
720
+ after: content,
721
+ kind: existed ? "overwrite" : "create",
722
+ });
723
+ const verb = existed ? "Wrote" : "Created";
724
+ return {
725
+ ok: true,
726
+ output: describeWrite(resolved, content, verb),
727
+ fileChanges: [change],
728
+ };
672
729
  });
673
- const verb = existed ? "Wrote" : "Created";
674
- return {
675
- ok: true,
676
- output: describeWrite(resolved, content, verb),
677
- fileChanges: [change],
678
- };
679
730
  }
680
731
  /** Atomically replace an inclusive, 1-indexed line range in an existing file. */
681
732
  export async function fsReplaceLines(path, startLine, endLine, content, options = {}) {
@@ -683,36 +734,37 @@ export async function fsReplaceLines(path, startLine, endLine, content, options
683
734
  if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1 || endLine < startLine) {
684
735
  return { ok: false, output: "fs.replaceLines requires integers with 1 <= startLine <= endLine", exitCode: 1 };
685
736
  }
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",
737
+ return withFileMutation(resolved, async () => {
738
+ const original = await readFile(resolved, "utf8");
739
+ const newline = original.includes("\r\n") ? "\r\n" : "\n";
740
+ const hadFinalNewline = original.endsWith("\n");
741
+ const lines = original.split(/\r?\n/);
742
+ if (hadFinalNewline)
743
+ lines.pop();
744
+ if (endLine > lines.length) {
745
+ return { ok: false, output: `fs.replaceLines range ${startLine}-${endLine} exceeds ${lines.length} lines`, exitCode: 1 };
746
+ }
747
+ const replacement = content === "" ? [] : content.split(/\r?\n/);
748
+ if (replacement.at(-1) === "")
749
+ replacement.pop();
750
+ lines.splice(startLine - 1, endLine - startLine + 1, ...replacement);
751
+ const next = lines.join(newline) + (hadFinalNewline ? newline : "");
752
+ await writeFileAtomic(resolved, next);
753
+ const verb = content === ""
754
+ ? `Deleted lines ${startLine}-${endLine} in`
755
+ : `Replaced lines ${startLine}-${endLine} in`;
756
+ const change = buildFileChange({
757
+ path: resolved,
758
+ before: original,
759
+ after: next,
760
+ kind: "edit",
761
+ });
762
+ return {
763
+ ok: true,
764
+ output: describeWrite(resolved, next, verb),
765
+ fileChanges: [change],
766
+ };
710
767
  });
711
- return {
712
- ok: true,
713
- output: describeWrite(resolved, next, verb),
714
- fileChanges: [change],
715
- };
716
768
  }
717
769
  const WRITE_MANY_MAX_FILES = 50;
718
770
  /**
@@ -754,18 +806,21 @@ export async function fsWriteMany(files, options = {}) {
754
806
  }
755
807
  try {
756
808
  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");
809
+ const mutation = await withFileMutation(resolved, async () => {
810
+ let before = "";
811
+ let existed = false;
812
+ try {
813
+ before = await readFile(resolved, "utf8");
814
+ existed = true;
815
+ }
816
+ catch (error) {
817
+ if (error?.code !== "ENOENT")
818
+ throw error;
819
+ }
820
+ await mkdir(dirname(resolved), { recursive: true });
821
+ await writeFileAtomic(resolved, file.content);
822
+ return { before, existed };
823
+ });
769
824
  const bytes = Buffer.byteLength(file.content, "utf8");
770
825
  const nLines = file.content.length === 0 ? 0 : file.content.split(/\r?\n/).length;
771
826
  const sha = createHash("sha256")
@@ -775,9 +830,9 @@ export async function fsWriteMany(files, options = {}) {
775
830
  written.push(`${resolved} (bytes=${bytes} lines=${nLines} sha256_12=${sha})`);
776
831
  changes.push(buildFileChange({
777
832
  path: resolved,
778
- before,
833
+ before: mutation.before,
779
834
  after: file.content,
780
- kind: existed ? "overwrite" : "create",
835
+ kind: mutation.existed ? "overwrite" : "create",
781
836
  }));
782
837
  }
783
838
  catch (error) {
@@ -989,101 +1044,103 @@ export async function fsSearch(pattern, path = safeCwd(), options = {}) {
989
1044
  */
990
1045
  export async function fsEdit(path, oldText, newText, expectedReplacements, options = {}) {
991
1046
  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;
1047
+ return withFileMutation(resolved, async () => {
1048
+ const priorStat = await stat(resolved).catch(() => undefined);
1049
+ if (priorStat?.isFile() && priorStat.size > LARGE_MUTATION_BYTES) {
1050
+ return {
1051
+ ok: false,
1052
+ output: `fs.edit refuses ${resolved}: ${priorStat.size.toLocaleString()} bytes exceeds the ${Math.round(LARGE_MUTATION_BYTES / (1024 * 1024))}MB whole-file edit limit. ` +
1053
+ `Use fs.replaceLines for a bounded line range, or a streaming tool (sed -i / awk) via shell.exec.`,
1054
+ exitCode: 1,
1055
+ };
1056
+ }
1057
+ const content = await readFile(resolved, "utf8");
1058
+ const expected = expectedReplacements ?? 1;
1059
+ let targetOldText = oldText;
1060
+ let targetNewText = newText;
1061
+ // Count occurrences (exact match first)
1062
+ let count = 0;
1063
+ let searchPos = 0;
1026
1064
  while (true) {
1027
- const idx = content.indexOf(candidateOld, altPos);
1065
+ const idx = content.indexOf(targetOldText, searchPos);
1028
1066
  if (idx === -1)
1029
1067
  break;
1030
- altCount += 1;
1031
- altPos = idx + candidateOld.length;
1032
- }
1033
- if (altCount > 0) {
1034
- targetOldText = candidateOld;
1035
- targetNewText = candidateNew;
1036
- count = altCount;
1068
+ count += 1;
1069
+ searchPos = idx + targetOldText.length;
1037
1070
  }
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;
1071
+ // Fallback: match CRLF / LF line ending differences or trailing whitespace
1072
+ if (count === 0) {
1073
+ const hasCRLF = content.includes("\r\n");
1074
+ const candidateOld = hasCRLF
1075
+ ? oldText.replace(/\r?\n/g, "\r\n")
1076
+ : oldText.replace(/\r\n/g, "\n");
1077
+ const candidateNew = hasCRLF
1078
+ ? newText.replace(/\r?\n/g, "\r\n")
1079
+ : newText.replace(/\r\n/g, "\n");
1080
+ let altCount = 0;
1081
+ let altPos = 0;
1082
+ while (true) {
1083
+ const idx = content.indexOf(candidateOld, altPos);
1084
+ if (idx === -1)
1085
+ break;
1086
+ altCount += 1;
1087
+ altPos = idx + candidateOld.length;
1088
+ }
1089
+ if (altCount > 0) {
1090
+ targetOldText = candidateOld;
1091
+ targetNewText = candidateNew;
1092
+ count = altCount;
1093
+ }
1094
+ else {
1095
+ const trimmedOld = candidateOld.trimEnd();
1096
+ if (trimmedOld.length > 0) {
1097
+ let trimCount = 0;
1098
+ let trimPos = 0;
1099
+ while (true) {
1100
+ const idx = content.indexOf(trimmedOld, trimPos);
1101
+ if (idx === -1)
1102
+ break;
1103
+ trimCount += 1;
1104
+ trimPos = idx + trimmedOld.length;
1105
+ }
1106
+ if (trimCount > 0) {
1107
+ targetOldText = trimmedOld;
1108
+ targetNewText = candidateNew.trimEnd();
1109
+ count = trimCount;
1110
+ }
1054
1111
  }
1055
1112
  }
1056
1113
  }
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) {
1114
+ if (count === 0) {
1115
+ return {
1116
+ ok: false,
1117
+ output: `No matches found for the search text in ${resolved}. The text to replace was not found.`,
1118
+ exitCode: 1,
1119
+ };
1120
+ }
1121
+ if (count !== expected) {
1122
+ return {
1123
+ ok: false,
1124
+ 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.`,
1125
+ exitCode: 1,
1126
+ };
1127
+ }
1128
+ const updated = content.replaceAll(targetOldText, targetNewText);
1129
+ // Atomic, mode-preserving, race-safe replacement.
1130
+ await writeFileAtomic(resolved, updated);
1131
+ const change = buildFileChange({
1132
+ path: resolved,
1133
+ before: content,
1134
+ after: updated,
1135
+ kind: "edit",
1136
+ });
1137
+ const preview = formatUnifiedPreview(change, { maxLines: 24 });
1066
1138
  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,
1139
+ ok: true,
1140
+ output: `Replaced ${count} occurrence(s) in ${resolved}.\n${preview}`,
1141
+ fileChanges: [change],
1070
1142
  };
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
1143
  });
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
1144
  }
1088
1145
  /**
1089
1146
  * Delete a file or directory. Requires the path to be inside the
@@ -1091,54 +1148,57 @@ export async function fsEdit(path, oldText, newText, expectedReplacements, optio
1091
1148
  */
1092
1149
  export async function fsDelete(path, recursive, options = {}) {
1093
1150
  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;
1151
+ return withFileMutation(resolved, async () => {
1152
+ try {
1153
+ // Snapshot text content for diff UI when deleting a single small file.
1154
+ let before = "";
1155
+ let canDiff = false;
1156
+ if (!recursive) {
1157
+ try {
1158
+ const st = await stat(resolved);
1159
+ if (st.isFile() && st.size <= 200_000) {
1160
+ before = await readFile(resolved, "utf8");
1161
+ canDiff = true;
1162
+ }
1163
+ }
1164
+ catch {
1165
+ /* ignore — delete may still succeed or fail below */
1104
1166
  }
1105
1167
  }
1106
- catch {
1107
- /* ignore delete may still succeed or fail below */
1168
+ if (recursive) {
1169
+ await rm(resolved, { recursive: true, force: false });
1170
+ return { ok: true, output: `Deleted (recursive): ${resolved}` };
1108
1171
  }
1172
+ await unlink(resolved);
1173
+ const change = canDiff
1174
+ ? buildFileChange({
1175
+ path: resolved,
1176
+ before,
1177
+ after: "",
1178
+ kind: "delete",
1179
+ })
1180
+ : buildFileChange({
1181
+ path: resolved,
1182
+ before: "",
1183
+ after: "",
1184
+ kind: "delete",
1185
+ });
1186
+ return {
1187
+ ok: true,
1188
+ output: `Deleted: ${resolved}`,
1189
+ fileChanges: [change],
1190
+ };
1109
1191
  }
1110
- if (recursive) {
1111
- await rm(resolved, { recursive: true, force: false });
1112
- return { ok: true, output: `Deleted (recursive): ${resolved}` };
1192
+ catch (error) {
1193
+ const msg = error instanceof Error ? error.message : String(error);
1194
+ return { ok: false, output: `Delete failed: ${msg}`, exitCode: 1 };
1113
1195
  }
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
- }
1196
+ });
1138
1197
  }
1139
1198
  export async function fsAppend(path, content, options = {}) {
1140
1199
  const resolved = ensureWriteAllowed(path, options.confirmed);
1141
1200
  const position = options.position ?? "end";
1201
+ const expectedPriorBytes = options.expectedPriorBytes;
1142
1202
  if (position !== "start" && position !== "end") {
1143
1203
  return {
1144
1204
  ok: false,
@@ -1146,94 +1206,80 @@ export async function fsAppend(path, content, options = {}) {
1146
1206
  exitCode: 1,
1147
1207
  };
1148
1208
  }
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);
1209
+ if (expectedPriorBytes !== undefined &&
1210
+ (!Number.isSafeInteger(expectedPriorBytes) || expectedPriorBytes < 0)) {
1169
1211
  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.`,
1212
+ ok: false,
1213
+ output: "fs.append expectedPriorBytes must be a non-negative safe integer.",
1214
+ exitCode: 1,
1174
1215
  };
1175
1216
  }
1176
- try {
1177
- original = await readFile(resolved, "utf8");
1178
- }
1179
- catch (error) {
1180
- if (error?.code !== "ENOENT") {
1181
- throw error;
1217
+ return withFileMutation(resolved, async () => {
1218
+ const priorStat = await stat(resolved).catch(() => undefined);
1219
+ if (priorStat?.isFile() &&
1220
+ priorStat.size > LARGE_MUTATION_BYTES &&
1221
+ position === "end") {
1222
+ const priorBytes = priorStat.size;
1223
+ if (expectedPriorBytes !== undefined && expectedPriorBytes !== priorBytes) {
1224
+ return appendPreconditionFailure(resolved, expectedPriorBytes, priorBytes);
1225
+ }
1226
+ await appendFile(resolved, content, "utf8");
1227
+ const afterBytes = (await stat(resolved).catch(() => undefined))?.size ??
1228
+ priorBytes + Buffer.byteLength(content, "utf8");
1229
+ return {
1230
+ ok: true,
1231
+ output: describeWrite(resolved, content, "Appended (end) to") +
1232
+ `\n prior_bytes=${priorBytes} after_bytes=${afterBytes}` +
1233
+ `\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.`,
1234
+ };
1235
+ }
1236
+ let original = "";
1237
+ try {
1238
+ original = await readFile(resolved, "utf8");
1182
1239
  }
1183
- if (typeof options.expectedPriorBytes === "number" &&
1184
- options.expectedPriorBytes !== 0) {
1240
+ catch (error) {
1241
+ if (error?.code !== "ENOENT")
1242
+ throw error;
1243
+ if (expectedPriorBytes !== undefined && expectedPriorBytes !== 0) {
1244
+ return {
1245
+ ok: false,
1246
+ output: `fs.append integrity check failed: expected prior bytes=${expectedPriorBytes} but file does not exist.`,
1247
+ exitCode: 1,
1248
+ };
1249
+ }
1250
+ await mkdir(dirname(resolved), { recursive: true });
1251
+ await writeFileAtomic(resolved, content);
1252
+ const change = buildFileChange({
1253
+ path: resolved,
1254
+ before: "",
1255
+ after: content,
1256
+ kind: "create",
1257
+ });
1185
1258
  return {
1186
- ok: false,
1187
- output: `fs.append integrity check failed: expected prior bytes=${options.expectedPriorBytes} but file does not exist.`,
1188
- exitCode: 1,
1259
+ ok: true,
1260
+ output: describeWrite(resolved, content, "Created"),
1261
+ fileChanges: [change],
1189
1262
  };
1190
1263
  }
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");
1264
+ const priorBytes = Buffer.byteLength(original, "utf8");
1265
+ if (expectedPriorBytes !== undefined && expectedPriorBytes !== priorBytes) {
1266
+ return appendPreconditionFailure(resolved, expectedPriorBytes, priorBytes);
1267
+ }
1268
+ const next = position === "start" ? content + original : original + content;
1269
+ await writeFileAtomic(resolved, next);
1270
+ const afterBytes = Buffer.byteLength(next, "utf8");
1194
1271
  const change = buildFileChange({
1195
1272
  path: resolved,
1196
- before: "",
1197
- after: content,
1198
- kind: "create",
1273
+ before: original,
1274
+ after: next,
1275
+ kind: "append",
1199
1276
  });
1200
1277
  return {
1201
1278
  ok: true,
1202
- output: describeWrite(resolved, content, "Created"),
1279
+ output: describeWrite(resolved, next, `Appended (${position}) to`) +
1280
+ `\n prior_bytes=${priorBytes} after_bytes=${afterBytes}`,
1203
1281
  fileChanges: [change],
1204
1282
  };
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
1283
  });
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
1284
  }
1239
1285
  //# sourceMappingURL=fs.js.map