@aexol/spectral 0.9.126 → 0.9.128
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/index.d.ts.map +1 -1
- package/dist/agent/index.js +5 -0
- package/dist/extensions/seo/analyzers/content.d.ts +29 -6
- package/dist/extensions/seo/analyzers/content.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/content.js +243 -8
- package/dist/extensions/seo/analyzers/crawler.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/crawler.js +133 -7
- package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/drift.js +86 -29
- package/dist/extensions/seo/analyzers/images.d.ts +10 -4
- package/dist/extensions/seo/analyzers/images.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/images.js +153 -5
- package/dist/extensions/seo/analyzers/performance.d.ts +3 -3
- package/dist/extensions/seo/analyzers/performance.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/performance.js +77 -6
- package/dist/extensions/seo/analyzers/readability.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/readability.js +17 -9
- package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/schema.js +99 -10
- package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/sitemap.js +57 -16
- package/dist/extensions/seo/analyzers/technical.d.ts +0 -7
- package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/technical.js +408 -37
- package/dist/extensions/seo/config.d.ts +2 -0
- package/dist/extensions/seo/config.d.ts.map +1 -1
- package/dist/extensions/seo/config.js +2 -0
- package/dist/extensions/seo/tools/batch.js +1 -1
- package/dist/extensions/seo/tools/content.d.ts.map +1 -1
- package/dist/extensions/seo/tools/content.js +5 -3
- package/dist/extensions/seo/tools/full-audit.d.ts +1 -1
- package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
- package/dist/extensions/seo/tools/full-audit.js +391 -66
- package/dist/extensions/seo/tools/helpers.d.ts +15 -4
- package/dist/extensions/seo/tools/helpers.d.ts.map +1 -1
- package/dist/extensions/seo/tools/helpers.js +57 -6
- package/dist/extensions/seo/tools/performance.d.ts.map +1 -1
- package/dist/extensions/seo/tools/performance.js +17 -11
- package/dist/extensions/seo/tools/report.d.ts.map +1 -1
- package/dist/extensions/seo/tools/report.js +513 -47
- package/dist/extensions/seo/types.d.ts +295 -3
- package/dist/extensions/seo/types.d.ts.map +1 -1
- package/dist/extensions/seo/utils/fetcher.d.ts +4 -0
- package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -1
- package/dist/extensions/seo/utils/fetcher.js +77 -11
- package/dist/extensions/seo/utils/parser.d.ts +3 -1
- package/dist/extensions/seo/utils/parser.d.ts.map +1 -1
- package/dist/extensions/seo/utils/parser.js +105 -25
- package/dist/extensions/seo/utils/renderer.d.ts +6 -1
- package/dist/extensions/seo/utils/renderer.d.ts.map +1 -1
- package/dist/extensions/seo/utils/renderer.js +207 -1
- package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -1
- package/dist/extensions/seo/utils/url-safety.js +19 -3
- package/dist/extensions/web/tools/fetch.js +2 -2
- package/dist/memory/tools/compact-context.d.ts.map +1 -1
- package/dist/memory/tools/compact-context.js +10 -3
- package/dist/sdk/agent-core/agent-loop.d.ts.map +1 -1
- package/dist/sdk/agent-core/agent-loop.js +87 -4
- package/dist/sdk/agent-core/types.d.ts +1 -0
- package/dist/sdk/agent-core/types.d.ts.map +1 -1
- package/dist/sdk/ai/utils/validation.d.ts +0 -7
- package/dist/sdk/ai/utils/validation.d.ts.map +1 -1
- package/dist/sdk/ai/utils/validation.js +21 -1
- package/dist/sdk/coding-agent/core/extensions/runner.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/runner.js +15 -1
- package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts +7 -2
- package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/apply-patch.js +219 -41
- package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts +30 -0
- package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts.map +1 -0
- package/dist/sdk/coding-agent/core/tools/atomic-write.js +42 -0
- package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts +10 -4
- package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/edit-diff.js +44 -24
- package/dist/sdk/coding-agent/core/tools/edit.d.ts +6 -0
- package/dist/sdk/coding-agent/core/tools/edit.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/edit.js +31 -5
- package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts +9 -3
- package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/file-mutation-queue.js +42 -3
- package/dist/sdk/coding-agent/core/tools/read.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/read.js +79 -47
- package/dist/sdk/coding-agent/core/tools/truncate.d.ts +4 -2
- package/dist/sdk/coding-agent/core/tools/truncate.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/truncate.js +43 -23
- package/dist/sdk/coding-agent/core/tools/write.d.ts +5 -8
- package/dist/sdk/coding-agent/core/tools/write.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/write.js +29 -11
- package/package.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { constants } from "fs";
|
|
2
|
-
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
|
|
2
|
+
import { access as fsAccess, readFile as fsReadFile, stat as fsStat, writeFile as fsWriteFile } from "fs/promises";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { renderDiff } from "./render-diff.js";
|
|
5
5
|
import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, } from "./edit-diff.js";
|
|
6
|
+
import { atomicWriteFile } from "./atomic-write.js";
|
|
6
7
|
import { withFileMutationQueue } from "./file-mutation-queue.js";
|
|
7
8
|
import { resolveToCwd } from "./path-utils.js";
|
|
8
9
|
import { shortenPath, str } from "./render-utils.js";
|
|
@@ -23,6 +24,7 @@ const defaultEditOperations = {
|
|
|
23
24
|
readFile: (path) => fsReadFile(path),
|
|
24
25
|
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
|
25
26
|
access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
|
|
27
|
+
stat: (path) => fsStat(path),
|
|
26
28
|
};
|
|
27
29
|
function prepareEditArguments(input) {
|
|
28
30
|
if (!input || typeof input !== "object") {
|
|
@@ -36,7 +38,10 @@ function prepareEditArguments(input) {
|
|
|
36
38
|
if (Array.isArray(parsed))
|
|
37
39
|
args.edits = parsed;
|
|
38
40
|
}
|
|
39
|
-
catch {
|
|
41
|
+
catch (error) {
|
|
42
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
43
|
+
throw new Error(`Invalid "edits" argument for edit tool: expected an array but received a string that could not be parsed as JSON. Parse error: ${reason}`);
|
|
44
|
+
}
|
|
40
45
|
}
|
|
41
46
|
const legacy = args;
|
|
42
47
|
if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") {
|
|
@@ -51,6 +56,14 @@ function validateEditInput(input) {
|
|
|
51
56
|
if (!Array.isArray(input.edits) || input.edits.length === 0) {
|
|
52
57
|
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
|
|
53
58
|
}
|
|
59
|
+
for (const edit of input.edits) {
|
|
60
|
+
if (!edit ||
|
|
61
|
+
typeof edit !== "object" ||
|
|
62
|
+
typeof edit.oldText !== "string" ||
|
|
63
|
+
typeof edit.newText !== "string") {
|
|
64
|
+
throw new Error("Edit tool input is invalid. Each edit must have string oldText and newText properties.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
54
67
|
return { path: input.path, edits: input.edits };
|
|
55
68
|
}
|
|
56
69
|
function formatEditCall(args, _theme) {
|
|
@@ -136,6 +149,8 @@ export function createEditToolDefinition(cwd, options) {
|
|
|
136
149
|
// Read the file.
|
|
137
150
|
const buffer = await ops.readFile(absolutePath);
|
|
138
151
|
const rawContent = buffer.toString("utf-8");
|
|
152
|
+
// Capture a freshness token so we can detect stale reads before writing.
|
|
153
|
+
const freshnessTokenBefore = ops.stat ? (await ops.stat(absolutePath)).mtimeMs : undefined;
|
|
139
154
|
// Check if aborted after reading.
|
|
140
155
|
if (aborted) {
|
|
141
156
|
return;
|
|
@@ -144,13 +159,24 @@ export function createEditToolDefinition(cwd, options) {
|
|
|
144
159
|
const { bom, text: content } = stripBom(rawContent);
|
|
145
160
|
const originalEnding = detectLineEnding(content);
|
|
146
161
|
const normalizedContent = normalizeToLF(content);
|
|
147
|
-
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
|
|
162
|
+
const { baseContent, newContent, usedFuzzyMatch } = applyEditsToNormalizedContent(normalizedContent, edits, path);
|
|
148
163
|
// Check if aborted before writing.
|
|
149
164
|
if (aborted) {
|
|
150
165
|
return;
|
|
151
166
|
}
|
|
167
|
+
// Detect stale file: re-check freshness token before writing.
|
|
168
|
+
if (freshnessTokenBefore !== undefined && ops.stat) {
|
|
169
|
+
const freshnessTokenAfter = (await ops.stat(absolutePath)).mtimeMs;
|
|
170
|
+
if (freshnessTokenAfter !== freshnessTokenBefore) {
|
|
171
|
+
if (signal) {
|
|
172
|
+
signal.removeEventListener("abort", onAbort);
|
|
173
|
+
}
|
|
174
|
+
reject(new Error(`File ${path} was modified since read. Re-read the file and retry your edit.`));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
152
178
|
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
153
|
-
await
|
|
179
|
+
await atomicWriteFile(absolutePath, finalContent);
|
|
154
180
|
// Check if aborted after writing.
|
|
155
181
|
if (aborted) {
|
|
156
182
|
return;
|
|
@@ -165,7 +191,7 @@ export function createEditToolDefinition(cwd, options) {
|
|
|
165
191
|
content: [
|
|
166
192
|
{
|
|
167
193
|
type: "text",
|
|
168
|
-
text: `Successfully replaced ${edits.length} block(s) in ${path}
|
|
194
|
+
text: `Successfully replaced ${edits.length} block(s) in ${path}.${usedFuzzyMatch ? " (used tolerant matching — verify the change)" : ""}`,
|
|
169
195
|
},
|
|
170
196
|
],
|
|
171
197
|
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Run `fn` while holding the per-file mutation lock for `filePath`.
|
|
3
|
+
*
|
|
4
|
+
* `filePath` may be a single path or an array of paths. For an array, each
|
|
5
|
+
* file gets its own per-file queue key (NOT a joined blob — a joined key would
|
|
6
|
+
* fail to serialize against single-path locks on the same file). Keys are
|
|
7
|
+
* sorted and acquired in a fixed order to avoid deadlock between two array
|
|
8
|
+
* locks that share a subset of files. The callback runs only once all locks
|
|
9
|
+
* are held and every lock is released in the `finally` block.
|
|
4
10
|
*/
|
|
5
|
-
export declare function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T>;
|
|
11
|
+
export declare function withFileMutationQueue<T>(filePath: string | string[], fn: () => Promise<T>): Promise<T>;
|
|
6
12
|
//# sourceMappingURL=file-mutation-queue.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-mutation-queue.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/file-mutation-queue.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"file-mutation-queue.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/file-mutation-queue.ts"],"names":[],"mappings":"AAqBA;;;;;;;;;GASG;AACH,wBAAsB,qBAAqB,CAAC,CAAC,EAC5C,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,EAC3B,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAClB,OAAO,CAAC,CAAC,CAAC,CA0BZ"}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
const fileMutationQueues = new Map();
|
|
4
|
+
/**
|
|
5
|
+
* Derive a stable per-file queue key. Tries realpath first (canonicalizes
|
|
6
|
+
* symlinks and existing files); falls back to the resolved path for files that
|
|
7
|
+
* do not exist yet (e.g. Add File targets). The fallback is essential —
|
|
8
|
+
* realpathSync throws ENOENT for non-existent files, which would otherwise
|
|
9
|
+
* produce a different key before vs after creation.
|
|
10
|
+
*/
|
|
4
11
|
function getMutationQueueKey(filePath) {
|
|
5
12
|
const resolvedPath = resolve(filePath);
|
|
6
13
|
try {
|
|
@@ -11,11 +18,43 @@ function getMutationQueueKey(filePath) {
|
|
|
11
18
|
}
|
|
12
19
|
}
|
|
13
20
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
21
|
+
* Run `fn` while holding the per-file mutation lock for `filePath`.
|
|
22
|
+
*
|
|
23
|
+
* `filePath` may be a single path or an array of paths. For an array, each
|
|
24
|
+
* file gets its own per-file queue key (NOT a joined blob — a joined key would
|
|
25
|
+
* fail to serialize against single-path locks on the same file). Keys are
|
|
26
|
+
* sorted and acquired in a fixed order to avoid deadlock between two array
|
|
27
|
+
* locks that share a subset of files. The callback runs only once all locks
|
|
28
|
+
* are held and every lock is released in the `finally` block.
|
|
16
29
|
*/
|
|
17
30
|
export async function withFileMutationQueue(filePath, fn) {
|
|
18
|
-
const
|
|
31
|
+
const paths = Array.isArray(filePath) ? filePath : [filePath];
|
|
32
|
+
// Empty/empty-array: no locks to acquire, just run the callback.
|
|
33
|
+
if (paths.length === 0) {
|
|
34
|
+
return fn();
|
|
35
|
+
}
|
|
36
|
+
// Dedupe and sort per-file keys. Sorting gives a stable acquisition order so
|
|
37
|
+
// two overlapping array locks cannot deadlock each other.
|
|
38
|
+
const keys = Array.from(new Set(paths.map((p) => getMutationQueueKey(p)))).sort();
|
|
39
|
+
// For a single unique key, use the original single-path fast path.
|
|
40
|
+
if (keys.length === 1) {
|
|
41
|
+
return withSingleFileMutationQueue(keys[0], fn);
|
|
42
|
+
}
|
|
43
|
+
// For multiple keys, chain each lock. Each step waits on the previous
|
|
44
|
+
// queue before acquiring the next, so the callback runs only once every
|
|
45
|
+
// per-file lock is held.
|
|
46
|
+
let chain = fn;
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
const next = chain;
|
|
49
|
+
chain = () => withSingleFileMutationQueue(key, next);
|
|
50
|
+
}
|
|
51
|
+
return chain();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Acquire a single per-file queue, run `fn`, then release. This is the core
|
|
55
|
+
* lock primitive shared by the single-path and array paths above.
|
|
56
|
+
*/
|
|
57
|
+
async function withSingleFileMutationQueue(key, fn) {
|
|
19
58
|
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
|
|
20
59
|
let releaseNext;
|
|
21
60
|
const nextQueue = new Promise((resolveQueue) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/read.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAI9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAM5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAI7D,OAAO,EAAoD,KAAK,gBAAgB,EAAgB,MAAM,eAAe,CAAC;AAEtH,QAAA,MAAM,UAAU;;;;EAId,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAEtD,MAAM,WAAW,eAAe;IAC/B,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AASD;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,qCAAqC;IACrC,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD,+CAA+C;IAC/C,MAAM,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,sEAAsE;IACtE,mBAAmB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CACnF;AAQD,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;CAC5B;AAkID,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,
|
|
1
|
+
{"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/read.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAI9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAM5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAI7D,OAAO,EAAoD,KAAK,gBAAgB,EAAgB,MAAM,eAAe,CAAC;AAEtH,QAAA,MAAM,UAAU;;;;EAId,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAEtD,MAAM,WAAW,eAAe;IAC/B,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AASD;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,qCAAqC;IACrC,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD,+CAA+C;IAC/C,MAAM,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,sEAAsE;IACtE,mBAAmB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CACnF;AAQD,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;CAC5B;AAkID,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,CA8KhE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAEnG"}
|
|
@@ -131,6 +131,13 @@ export function createReadToolDefinition(cwd, options) {
|
|
|
131
131
|
parameters: readSchema,
|
|
132
132
|
async execute(_toolCallId, { path, offset, limit }, signal, _onUpdate, ctx) {
|
|
133
133
|
const absolutePath = resolveReadPath(path, cwd);
|
|
134
|
+
// Validate offset/limit before doing any I/O. Distinguish undefined (unset) from 0.
|
|
135
|
+
if (offset !== undefined && offset <= 0) {
|
|
136
|
+
throw new Error("offset must be ≥ 1");
|
|
137
|
+
}
|
|
138
|
+
if (limit !== undefined && limit <= 0) {
|
|
139
|
+
throw new Error("limit must be ≥ 1");
|
|
140
|
+
}
|
|
134
141
|
return new Promise((resolve, reject) => {
|
|
135
142
|
if (signal?.aborted) {
|
|
136
143
|
reject(new Error("Operation aborted"));
|
|
@@ -199,60 +206,85 @@ export function createReadToolDefinition(cwd, options) {
|
|
|
199
206
|
else {
|
|
200
207
|
// Read text content.
|
|
201
208
|
const buffer = await ops.readFile(absolutePath);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
// Binary file detection: scan the first chunk for NUL bytes.
|
|
210
|
+
// Legitimate UTF-8 (including emoji and CJK) never contains NUL bytes, so
|
|
211
|
+
// a NUL byte is a strong signal that this is a non-text binary (executable,
|
|
212
|
+
// archive, etc.). Return a note instead of decoding garbage UTF-8.
|
|
213
|
+
const binaryScanLen = Math.min(buffer.length, 8192);
|
|
214
|
+
let isBinary = false;
|
|
215
|
+
for (let i = 0; i < binaryScanLen; i++) {
|
|
216
|
+
if (buffer[i] === 0x00) {
|
|
217
|
+
isBinary = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
211
220
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const endLine = Math.min(startLine + limit, allLines.length);
|
|
217
|
-
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
|
218
|
-
userLimitedLines = endLine - startLine;
|
|
221
|
+
if (isBinary) {
|
|
222
|
+
content = [
|
|
223
|
+
{ type: "text", text: `[Binary file, ${formatSize(buffer.length)}, not displayed]` },
|
|
224
|
+
];
|
|
219
225
|
}
|
|
220
226
|
else {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
227
|
+
const textContent = buffer.toString("utf-8");
|
|
228
|
+
let allLines = textContent.split("\n");
|
|
229
|
+
// A trailing newline produces an extra empty element on split; drop it so the
|
|
230
|
+
// reported line count and all offset/limit math match the real content.
|
|
231
|
+
const totalFileLines = textContent.endsWith("\n") ? allLines.length - 1 : allLines.length;
|
|
232
|
+
if (textContent.endsWith("\n")) {
|
|
233
|
+
allLines = allLines.slice(0, -1);
|
|
234
|
+
}
|
|
235
|
+
// Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.
|
|
236
|
+
// Use explicit undefined check so offset=0 is rejected above rather than treated as "no offset".
|
|
237
|
+
const startLine = offset !== undefined ? Math.max(0, offset - 1) : 0;
|
|
238
|
+
const startLineDisplay = startLine + 1;
|
|
239
|
+
// Check if offset is out of bounds.
|
|
240
|
+
if (startLine >= allLines.length) {
|
|
241
|
+
throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`);
|
|
242
|
+
}
|
|
243
|
+
let selectedContent;
|
|
244
|
+
let userLimitedLines;
|
|
245
|
+
// If limit is specified by the user, honor it first. Otherwise truncateHead decides.
|
|
246
|
+
if (limit !== undefined) {
|
|
247
|
+
const endLine = Math.min(startLine + limit, allLines.length);
|
|
248
|
+
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
|
249
|
+
userLimitedLines = endLine - startLine;
|
|
239
250
|
}
|
|
240
251
|
else {
|
|
241
|
-
|
|
252
|
+
selectedContent = allLines.slice(startLine).join("\n");
|
|
242
253
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
+
// Apply truncation, respecting both line and byte limits.
|
|
255
|
+
const truncation = truncateHead(selectedContent);
|
|
256
|
+
let outputText;
|
|
257
|
+
if (truncation.firstLineExceedsLimit) {
|
|
258
|
+
// First line alone exceeds the byte limit. Point the model at a bash fallback.
|
|
259
|
+
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
|
|
260
|
+
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
|
|
261
|
+
details = { truncation };
|
|
262
|
+
}
|
|
263
|
+
else if (truncation.truncated) {
|
|
264
|
+
// Truncation occurred. Build an actionable continuation notice.
|
|
265
|
+
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
|
266
|
+
const nextOffset = endLineDisplay + 1;
|
|
267
|
+
outputText = truncation.content;
|
|
268
|
+
if (truncation.truncatedBy === "lines") {
|
|
269
|
+
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
|
|
273
|
+
}
|
|
274
|
+
details = { truncation };
|
|
275
|
+
}
|
|
276
|
+
else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
|
|
277
|
+
// User-specified limit stopped early, but the file still has more content.
|
|
278
|
+
const remaining = allLines.length - (startLine + userLimitedLines);
|
|
279
|
+
const nextOffset = startLine + userLimitedLines + 1;
|
|
280
|
+
outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
// No truncation and no remaining user-limited content.
|
|
284
|
+
outputText = truncation.content;
|
|
285
|
+
}
|
|
286
|
+
content = [{ type: "text", text: outputText }];
|
|
254
287
|
}
|
|
255
|
-
content = [{ type: "text", text: outputText }];
|
|
256
288
|
}
|
|
257
289
|
if (aborted)
|
|
258
290
|
return;
|
|
@@ -48,8 +48,10 @@ export declare function formatSize(bytes: number): string;
|
|
|
48
48
|
* Truncate content from the head (keep first N lines/bytes).
|
|
49
49
|
* Suitable for file reads where you want to see the beginning.
|
|
50
50
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
51
|
+
* If a single line exceeds the byte limit, that line is byte-capped
|
|
52
|
+
* (keeping a valid UTF-8 boundary) rather than aborting the whole read;
|
|
53
|
+
* subsequent lines are still scanned. firstLineExceedsLimit is set true only
|
|
54
|
+
* when the oversized line is the first requested line.
|
|
53
55
|
*/
|
|
54
56
|
export declare function truncateHead(content: string, options?: TruncationOptions): TruncationResult;
|
|
55
57
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"truncate.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/truncate.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AACtC,eAAO,MAAM,iBAAiB,QAAY,CAAC;AAC3C,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,MAAM,WAAW,gBAAgB;IAChC,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,kCAAkC;IAClC,SAAS,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CAAC;IACtC,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,eAAe,EAAE,OAAO,CAAC;IACzB,2EAA2E;IAC3E,qBAAqB,EAAE,OAAO,CAAC;IAC/B,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IACjC,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAaD;;GAEG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED
|
|
1
|
+
{"version":3,"file":"truncate.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/truncate.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AACtC,eAAO,MAAM,iBAAiB,QAAY,CAAC;AAC3C,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,MAAM,WAAW,gBAAgB;IAChC,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,kCAAkC;IAClC,SAAS,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CAAC;IACtC,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,eAAe,EAAE,OAAO,CAAC;IACzB,2EAA2E;IAC3E,qBAAqB,EAAE,OAAO,CAAC;IAC/B,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IACjC,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAaD;;GAEG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAkF/F;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAyE/F;AA2CD;;;GAGG;AACH,wBAAgB,YAAY,CAC3B,IAAI,EAAE,MAAM,EACZ,QAAQ,GAAE,MAA6B,GACrC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,OAAO,CAAA;CAAE,CAKzC"}
|
|
@@ -38,8 +38,10 @@ export function formatSize(bytes) {
|
|
|
38
38
|
* Truncate content from the head (keep first N lines/bytes).
|
|
39
39
|
* Suitable for file reads where you want to see the beginning.
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* If a single line exceeds the byte limit, that line is byte-capped
|
|
42
|
+
* (keeping a valid UTF-8 boundary) rather than aborting the whole read;
|
|
43
|
+
* subsequent lines are still scanned. firstLineExceedsLimit is set true only
|
|
44
|
+
* when the oversized line is the first requested line.
|
|
43
45
|
*/
|
|
44
46
|
export function truncateHead(content, options = {}) {
|
|
45
47
|
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
|
@@ -63,30 +65,31 @@ export function truncateHead(content, options = {}) {
|
|
|
63
65
|
maxBytes,
|
|
64
66
|
};
|
|
65
67
|
}
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
content: "",
|
|
71
|
-
truncated: true,
|
|
72
|
-
truncatedBy: "bytes",
|
|
73
|
-
totalLines,
|
|
74
|
-
totalBytes,
|
|
75
|
-
outputLines: 0,
|
|
76
|
-
outputBytes: 0,
|
|
77
|
-
lastLinePartial: false,
|
|
78
|
-
firstLineExceedsLimit: true,
|
|
79
|
-
maxLines,
|
|
80
|
-
maxBytes,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
// Collect complete lines that fit
|
|
68
|
+
// Scan lines, byte-capping any single oversized line instead of aborting.
|
|
69
|
+
// firstLineExceedsLimit is surfaced only when the very first requested line
|
|
70
|
+
// is the oversized one, so callers that render a per-line hint (e.g. read)
|
|
71
|
+
// keep doing so without losing the rest of the content.
|
|
84
72
|
const outputLinesArr = [];
|
|
85
73
|
let outputBytesCount = 0;
|
|
86
74
|
let truncatedBy = "lines";
|
|
75
|
+
let firstLineExceedsLimit = false;
|
|
76
|
+
let lastLinePartial = false;
|
|
87
77
|
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
|
88
78
|
const line = lines[i];
|
|
89
|
-
const
|
|
79
|
+
const newlineCost = i > 0 ? 1 : 0; // +1 for joining newline
|
|
80
|
+
let lineBytes = Buffer.byteLength(line, "utf-8");
|
|
81
|
+
// A single line larger than the whole byte budget: cap it in place.
|
|
82
|
+
if (lineBytes > maxBytes) {
|
|
83
|
+
if (i === 0)
|
|
84
|
+
firstLineExceedsLimit = true;
|
|
85
|
+
const capped = truncateStringToBytesFromStart(line, maxBytes);
|
|
86
|
+
outputLinesArr.push(capped);
|
|
87
|
+
outputBytesCount = maxBytes;
|
|
88
|
+
lastLinePartial = true;
|
|
89
|
+
truncatedBy = "bytes";
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
lineBytes += newlineCost;
|
|
90
93
|
if (outputBytesCount + lineBytes > maxBytes) {
|
|
91
94
|
truncatedBy = "bytes";
|
|
92
95
|
break;
|
|
@@ -108,8 +111,8 @@ export function truncateHead(content, options = {}) {
|
|
|
108
111
|
totalBytes,
|
|
109
112
|
outputLines: outputLinesArr.length,
|
|
110
113
|
outputBytes: finalOutputBytes,
|
|
111
|
-
lastLinePartial
|
|
112
|
-
firstLineExceedsLimit
|
|
114
|
+
lastLinePartial,
|
|
115
|
+
firstLineExceedsLimit,
|
|
113
116
|
maxLines,
|
|
114
117
|
maxBytes,
|
|
115
118
|
};
|
|
@@ -202,6 +205,23 @@ function truncateStringToBytesFromEnd(str, maxBytes) {
|
|
|
202
205
|
}
|
|
203
206
|
return buf.slice(start).toString("utf-8");
|
|
204
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Truncate a string to fit within a byte limit (from the start).
|
|
210
|
+
* Handles multi-byte UTF-8 characters correctly.
|
|
211
|
+
*/
|
|
212
|
+
function truncateStringToBytesFromStart(str, maxBytes) {
|
|
213
|
+
const buf = Buffer.from(str, "utf-8");
|
|
214
|
+
if (buf.length <= maxBytes) {
|
|
215
|
+
return str;
|
|
216
|
+
}
|
|
217
|
+
let end = maxBytes;
|
|
218
|
+
// Walk back to a valid UTF-8 character boundary so we don't slice
|
|
219
|
+
// through the middle of a multi-byte sequence.
|
|
220
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) {
|
|
221
|
+
end--;
|
|
222
|
+
}
|
|
223
|
+
return buf.slice(0, end).toString("utf-8");
|
|
224
|
+
}
|
|
205
225
|
/**
|
|
206
226
|
* Truncate a single line to max characters, adding [truncated] suffix.
|
|
207
227
|
* Used for grep match lines.
|
|
@@ -6,18 +6,15 @@ declare const writeSchema: Type.TObject<{
|
|
|
6
6
|
content: Type.TString;
|
|
7
7
|
}>;
|
|
8
8
|
export type WriteToolInput = Static<typeof writeSchema>;
|
|
9
|
-
/**
|
|
10
|
-
* Pluggable operations for the write tool.
|
|
11
|
-
* Override these to delegate file writing to remote systems (for example SSH).
|
|
12
|
-
*/
|
|
13
9
|
export interface WriteOperations {
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Optional atomic write override (temp file + rename). When omitted,
|
|
12
|
+
* the default {@link atomicWriteFile} helper is used.
|
|
13
|
+
*/
|
|
14
|
+
atomicWrite?: (absolutePath: string, content: string) => Promise<void>;
|
|
17
15
|
mkdir: (dir: string) => Promise<void>;
|
|
18
16
|
}
|
|
19
17
|
export interface WriteToolOptions {
|
|
20
|
-
/** Custom operations for file writing. Default: local filesystem */
|
|
21
18
|
operations?: WriteOperations;
|
|
22
19
|
}
|
|
23
20
|
export declare function createWriteToolDefinition(cwd: string, options?: WriteToolOptions): ToolDefinition<typeof writeSchema, undefined>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/write.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAG9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/write.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAG9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAe7D,QAAA,MAAM,WAAW;;;EAKf,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,OAAO,WAAW,CAAC,CAAC;AACxD,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,WAAW,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAID,MAAM,WAAW,gBAAgB;IAC/B,UAAU,CAAC,EAAE,eAAe,CAAC;CAC9B;AA6DD,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,gBAAgB,GACzB,cAAc,CAAC,OAAO,WAAW,EAAE,SAAS,CAAC,CA+E/C;AACD,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,gBAAgB,GACzB,SAAS,CAAC,OAAO,WAAW,CAAC,CAE/B"}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import { mkdir as fsMkdir
|
|
1
|
+
import { mkdir as fsMkdir } from "fs/promises";
|
|
2
2
|
import { dirname } from "path";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { getLanguageFromPath, highlightCode } from "../theme.js";
|
|
5
|
+
import { atomicWriteFile, defaultAtomicWriteOperations, } from "./atomic-write.js";
|
|
5
6
|
import { withFileMutationQueue } from "./file-mutation-queue.js";
|
|
6
7
|
import { resolveToCwd } from "./path-utils.js";
|
|
7
|
-
import { normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.js";
|
|
8
|
+
import { normalizeDisplayText, replaceTabs, shortenPath, str, } from "./render-utils.js";
|
|
8
9
|
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
|
|
9
10
|
const writeSchema = Type.Object({
|
|
10
|
-
path: Type.String({
|
|
11
|
+
path: Type.String({
|
|
12
|
+
description: "Path to the file to write (relative or absolute)",
|
|
13
|
+
}),
|
|
11
14
|
content: Type.String({ description: "Content to write to the file" }),
|
|
12
15
|
});
|
|
13
16
|
const defaultWriteOperations = {
|
|
14
|
-
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
|
15
17
|
mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => { }),
|
|
16
18
|
};
|
|
17
19
|
function trimTrailingEmptyLines(lines) {
|
|
@@ -62,6 +64,7 @@ function formatWriteResult(result, _theme) {
|
|
|
62
64
|
}
|
|
63
65
|
export function createWriteToolDefinition(cwd, options) {
|
|
64
66
|
const ops = options?.operations ?? defaultWriteOperations;
|
|
67
|
+
const atomicOps = defaultAtomicWriteOperations;
|
|
65
68
|
return {
|
|
66
69
|
name: "write",
|
|
67
70
|
label: "write",
|
|
@@ -73,30 +76,45 @@ export function createWriteToolDefinition(cwd, options) {
|
|
|
73
76
|
const absolutePath = resolveToCwd(path, cwd);
|
|
74
77
|
const dir = dirname(absolutePath);
|
|
75
78
|
return withFileMutationQueue(absolutePath, () => new Promise((resolve, reject) => {
|
|
79
|
+
// Abort BEFORE the write begins -> reject immediately.
|
|
76
80
|
if (signal?.aborted) {
|
|
77
81
|
reject(new Error("Operation aborted"));
|
|
78
82
|
return;
|
|
79
83
|
}
|
|
80
84
|
let aborted = false;
|
|
85
|
+
let writeStarted = false;
|
|
81
86
|
const onAbort = () => {
|
|
82
87
|
aborted = true;
|
|
83
|
-
reject
|
|
88
|
+
// W2: only reject when aborted BEFORE the write begins. An abort
|
|
89
|
+
// that fires during/after the write must not swallow the
|
|
90
|
+
// successful resolve once the write completes.
|
|
91
|
+
if (!writeStarted) {
|
|
92
|
+
reject(new Error("Operation aborted"));
|
|
93
|
+
}
|
|
84
94
|
};
|
|
85
95
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
86
96
|
(async () => {
|
|
87
97
|
try {
|
|
88
|
-
// Create parent directories if needed.
|
|
89
98
|
await ops.mkdir(dir);
|
|
99
|
+
// Reject if aborted before the write begins.
|
|
90
100
|
if (aborted)
|
|
91
101
|
return;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
writeStarted = true;
|
|
103
|
+
// W1 + W5: atomic write (temp + rename). On failure the
|
|
104
|
+
// temp is cleaned up and the target is left untouched.
|
|
105
|
+
const writeFn = ops.atomicWrite ??
|
|
106
|
+
((p, c) => atomicWriteFile(p, c, "utf-8", atomicOps));
|
|
107
|
+
await writeFn(absolutePath, content);
|
|
108
|
+
// W2: once the write has completed successfully, resolve
|
|
109
|
+
// with the success content even if the abort signal
|
|
110
|
+
// fired mid-write. Do NOT swallow the resolve.
|
|
96
111
|
signal?.removeEventListener("abort", onAbort);
|
|
97
112
|
resolve({
|
|
98
113
|
content: [
|
|
99
|
-
{
|
|
114
|
+
{
|
|
115
|
+
type: "text",
|
|
116
|
+
text: `Successfully wrote ${Buffer.byteLength(content, "utf-8")} bytes to ${path}`,
|
|
117
|
+
},
|
|
100
118
|
],
|
|
101
119
|
details: undefined,
|
|
102
120
|
});
|