@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.
- package/dist/agent/runner.js +37 -6
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tool-history.js +4 -2
- package/dist/agent/tool-history.js.map +1 -1
- package/dist/app/controllers/session-prompt-queue.d.ts +6 -7
- package/dist/app/controllers/session-prompt-queue.js +80 -85
- package/dist/app/controllers/session-prompt-queue.js.map +1 -1
- package/dist/app/controllers/turn-continuation.js +3 -0
- package/dist/app/controllers/turn-continuation.js.map +1 -1
- package/dist/llm/token-estimate-calibration.d.ts +0 -1
- package/dist/llm/token-estimate-calibration.js +3 -3
- package/dist/llm/token-estimate-calibration.js.map +1 -1
- package/dist/llm/tool-protocol.d.ts +1 -0
- package/dist/llm/tool-protocol.js +29 -7
- package/dist/llm/tool-protocol.js.map +1 -1
- package/dist/prompts/embedded.js +1 -1
- package/dist/prompts/embedded.js.map +1 -1
- package/dist/prompts/system.agent.md +5 -5
- package/dist/session-runtime/binding.d.ts +1 -1
- package/dist/session-runtime/binding.js +3 -1
- package/dist/session-runtime/binding.js.map +1 -1
- package/dist/session-runtime/child-bridge.d.ts +7 -2
- package/dist/session-runtime/child-bridge.js +55 -4
- package/dist/session-runtime/child-bridge.js.map +1 -1
- package/dist/session-runtime/client.d.ts +5 -0
- package/dist/session-runtime/client.js +13 -5
- package/dist/session-runtime/client.js.map +1 -1
- package/dist/session-runtime/host.d.ts +7 -0
- package/dist/session-runtime/host.js +112 -19
- package/dist/session-runtime/host.js.map +1 -1
- package/dist/session-runtime/types.d.ts +10 -0
- package/dist/tools/fs.d.ts +0 -5
- package/dist/tools/fs.js +318 -272
- package/dist/tools/fs.js.map +1 -1
- package/dist/tools/shell.d.ts +5 -2
- package/dist/tools/shell.js +313 -20
- package/dist/tools/shell.js.map +1 -1
- package/dist/tui-v2/app/App.js +72 -55
- package/dist/tui-v2/app/App.js.map +1 -1
- package/dist/tui-v2/app/layout-widths.d.ts +21 -0
- package/dist/tui-v2/app/layout-widths.js +50 -0
- package/dist/tui-v2/app/layout-widths.js.map +1 -0
- package/dist/tui-v2/bootstrap/resize-repaint.d.ts +8 -1
- package/dist/tui-v2/bootstrap/resize-repaint.js +8 -0
- package/dist/tui-v2/bootstrap/resize-repaint.js.map +1 -1
- package/dist/tui-v2/bootstrap/start-tui-v2.js +22 -7
- package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
- package/dist/tui-v2/components/modal/secret-modal.js +10 -10
- package/dist/tui-v2/components/modal/secret-modal.js.map +1 -1
- package/dist/tui-v2/components/pager/pager.js +6 -1
- package/dist/tui-v2/components/pager/pager.js.map +1 -1
- package/dist/tui-v2/components/transcript/assistant-message.d.ts +2 -0
- package/dist/tui-v2/components/transcript/assistant-message.js +4 -2
- package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
- package/dist/tui-v2/components/transcript/compacted-row.js +4 -1
- package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
- package/dist/tui-v2/components/transcript/tool-card.js +4 -1
- package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
- package/dist/tui-v2/components/transcript/transcript-row.js +1 -1
- package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
- package/dist/tui-v2/input/chord-from-opentui-key.d.ts +6 -0
- package/dist/tui-v2/input/chord-from-opentui-key.js +32 -6
- package/dist/tui-v2/input/chord-from-opentui-key.js.map +1 -1
- package/dist/tui-v2/input/escape-cancellation.d.ts +7 -0
- package/dist/tui-v2/input/escape-cancellation.js +11 -0
- package/dist/tui-v2/input/escape-cancellation.js.map +1 -0
- package/dist/tui-v2/rendering/pager-markdown.d.ts +4 -0
- package/dist/tui-v2/rendering/pager-markdown.js +2 -0
- package/dist/tui-v2/rendering/pager-markdown.js.map +1 -1
- package/dist/ui-core/actions/chord.d.ts +4 -0
- package/dist/ui-core/actions/chord.js.map +1 -1
- package/dist/ui-core/bootstrap/capabilities.d.ts +6 -0
- package/dist/ui-core/bootstrap/capabilities.js +39 -4
- package/dist/ui-core/bootstrap/capabilities.js.map +1 -1
- package/dist/ui-core/react/use-session-state.d.ts +9 -2
- package/dist/ui-core/react/use-session-state.js +35 -4
- package/dist/ui-core/react/use-session-state.js.map +1 -1
- package/dist/ui-core/rendering/code-block.d.ts +10 -5
- package/dist/ui-core/rendering/code-block.js +68 -40
- package/dist/ui-core/rendering/code-block.js.map +1 -1
- package/dist/ui-core/rendering/markdown.d.ts +6 -3
- package/dist/ui-core/rendering/markdown.js +92 -71
- package/dist/ui-core/rendering/markdown.js.map +1 -1
- package/dist/ui-core/rendering/render-markdown-lines.d.ts +4 -0
- package/dist/ui-core/rendering/render-markdown-lines.js +4 -1
- package/dist/ui-core/rendering/render-markdown-lines.js.map +1 -1
- package/dist/ui-core/rendering/streaming-markdown.js +14 -1
- package/dist/ui-core/rendering/streaming-markdown.js.map +1 -1
- package/dist/version.generated.d.ts +2 -2
- package/dist/version.generated.js +2 -2
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
replacement.
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
`
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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(
|
|
1065
|
+
const idx = content.indexOf(targetOldText, searchPos);
|
|
1028
1066
|
if (idx === -1)
|
|
1029
1067
|
break;
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
|
|
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:
|
|
1068
|
-
output: `
|
|
1069
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
return { ok:
|
|
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
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
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:
|
|
1171
|
-
output:
|
|
1172
|
-
|
|
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
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
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:
|
|
1187
|
-
output:
|
|
1188
|
-
|
|
1259
|
+
ok: true,
|
|
1260
|
+
output: describeWrite(resolved, content, "Created"),
|
|
1261
|
+
fileChanges: [change],
|
|
1189
1262
|
};
|
|
1190
1263
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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:
|
|
1198
|
-
kind: "
|
|
1273
|
+
before: original,
|
|
1274
|
+
after: next,
|
|
1275
|
+
kind: "append",
|
|
1199
1276
|
});
|
|
1200
1277
|
return {
|
|
1201
1278
|
ok: true,
|
|
1202
|
-
output: describeWrite(resolved,
|
|
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
|