@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.
- 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/classic/input/terminal-sequences.d.ts +1 -3
- package/dist/classic/input/terminal-sequences.js +1 -3
- package/dist/classic/input/terminal-sequences.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 +7 -7
- 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/definitions.js +2 -2
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/fs.d.ts +0 -5
- package/dist/tools/fs.js +363 -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 +67 -61
- 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/cancel-timing.d.ts +6 -0
- package/dist/ui-core/actions/cancel-timing.js +7 -0
- package/dist/ui-core/actions/cancel-timing.js.map +1 -0
- 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 +51 -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/ui-core/rendering/tool-presenter.js +6 -2
- package/dist/ui-core/rendering/tool-presenter.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,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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
replacement.
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
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;
|
|
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(
|
|
1090
|
+
const idx = content.indexOf(targetOldText, searchPos);
|
|
1028
1091
|
if (idx === -1)
|
|
1029
1092
|
break;
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
|
|
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:
|
|
1068
|
-
output: `
|
|
1069
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
return { ok:
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
1234
|
+
if (expectedPriorBytes !== undefined &&
|
|
1235
|
+
(!Number.isSafeInteger(expectedPriorBytes) || expectedPriorBytes < 0)) {
|
|
1169
1236
|
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.`,
|
|
1237
|
+
ok: false,
|
|
1238
|
+
output: "fs.append expectedPriorBytes must be a non-negative safe integer.",
|
|
1239
|
+
exitCode: 1,
|
|
1174
1240
|
};
|
|
1175
1241
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
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:
|
|
1187
|
-
output:
|
|
1188
|
-
|
|
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
|
-
|
|
1192
|
-
await
|
|
1193
|
-
|
|
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:
|
|
1198
|
-
kind: "
|
|
1315
|
+
before: original,
|
|
1316
|
+
after: next,
|
|
1317
|
+
kind: "append",
|
|
1199
1318
|
});
|
|
1200
1319
|
return {
|
|
1201
1320
|
ok: true,
|
|
1202
|
-
output: describeWrite(resolved,
|
|
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
|