@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.
Files changed (89) hide show
  1. package/dist/agent/index.d.ts.map +1 -1
  2. package/dist/agent/index.js +5 -0
  3. package/dist/extensions/seo/analyzers/content.d.ts +29 -6
  4. package/dist/extensions/seo/analyzers/content.d.ts.map +1 -1
  5. package/dist/extensions/seo/analyzers/content.js +243 -8
  6. package/dist/extensions/seo/analyzers/crawler.d.ts.map +1 -1
  7. package/dist/extensions/seo/analyzers/crawler.js +133 -7
  8. package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -1
  9. package/dist/extensions/seo/analyzers/drift.js +86 -29
  10. package/dist/extensions/seo/analyzers/images.d.ts +10 -4
  11. package/dist/extensions/seo/analyzers/images.d.ts.map +1 -1
  12. package/dist/extensions/seo/analyzers/images.js +153 -5
  13. package/dist/extensions/seo/analyzers/performance.d.ts +3 -3
  14. package/dist/extensions/seo/analyzers/performance.d.ts.map +1 -1
  15. package/dist/extensions/seo/analyzers/performance.js +77 -6
  16. package/dist/extensions/seo/analyzers/readability.d.ts.map +1 -1
  17. package/dist/extensions/seo/analyzers/readability.js +17 -9
  18. package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -1
  19. package/dist/extensions/seo/analyzers/schema.js +99 -10
  20. package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -1
  21. package/dist/extensions/seo/analyzers/sitemap.js +57 -16
  22. package/dist/extensions/seo/analyzers/technical.d.ts +0 -7
  23. package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -1
  24. package/dist/extensions/seo/analyzers/technical.js +408 -37
  25. package/dist/extensions/seo/config.d.ts +2 -0
  26. package/dist/extensions/seo/config.d.ts.map +1 -1
  27. package/dist/extensions/seo/config.js +2 -0
  28. package/dist/extensions/seo/tools/batch.js +1 -1
  29. package/dist/extensions/seo/tools/content.d.ts.map +1 -1
  30. package/dist/extensions/seo/tools/content.js +5 -3
  31. package/dist/extensions/seo/tools/full-audit.d.ts +1 -1
  32. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  33. package/dist/extensions/seo/tools/full-audit.js +391 -66
  34. package/dist/extensions/seo/tools/helpers.d.ts +15 -4
  35. package/dist/extensions/seo/tools/helpers.d.ts.map +1 -1
  36. package/dist/extensions/seo/tools/helpers.js +57 -6
  37. package/dist/extensions/seo/tools/performance.d.ts.map +1 -1
  38. package/dist/extensions/seo/tools/performance.js +17 -11
  39. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  40. package/dist/extensions/seo/tools/report.js +513 -47
  41. package/dist/extensions/seo/types.d.ts +295 -3
  42. package/dist/extensions/seo/types.d.ts.map +1 -1
  43. package/dist/extensions/seo/utils/fetcher.d.ts +4 -0
  44. package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -1
  45. package/dist/extensions/seo/utils/fetcher.js +77 -11
  46. package/dist/extensions/seo/utils/parser.d.ts +3 -1
  47. package/dist/extensions/seo/utils/parser.d.ts.map +1 -1
  48. package/dist/extensions/seo/utils/parser.js +105 -25
  49. package/dist/extensions/seo/utils/renderer.d.ts +6 -1
  50. package/dist/extensions/seo/utils/renderer.d.ts.map +1 -1
  51. package/dist/extensions/seo/utils/renderer.js +207 -1
  52. package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -1
  53. package/dist/extensions/seo/utils/url-safety.js +19 -3
  54. package/dist/extensions/web/tools/fetch.js +2 -2
  55. package/dist/memory/tools/compact-context.d.ts.map +1 -1
  56. package/dist/memory/tools/compact-context.js +10 -3
  57. package/dist/sdk/agent-core/agent-loop.d.ts.map +1 -1
  58. package/dist/sdk/agent-core/agent-loop.js +87 -4
  59. package/dist/sdk/agent-core/types.d.ts +1 -0
  60. package/dist/sdk/agent-core/types.d.ts.map +1 -1
  61. package/dist/sdk/ai/utils/validation.d.ts +0 -7
  62. package/dist/sdk/ai/utils/validation.d.ts.map +1 -1
  63. package/dist/sdk/ai/utils/validation.js +21 -1
  64. package/dist/sdk/coding-agent/core/extensions/runner.d.ts.map +1 -1
  65. package/dist/sdk/coding-agent/core/extensions/runner.js +15 -1
  66. package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts +7 -2
  67. package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts.map +1 -1
  68. package/dist/sdk/coding-agent/core/tools/apply-patch.js +219 -41
  69. package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts +30 -0
  70. package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts.map +1 -0
  71. package/dist/sdk/coding-agent/core/tools/atomic-write.js +42 -0
  72. package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts +10 -4
  73. package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts.map +1 -1
  74. package/dist/sdk/coding-agent/core/tools/edit-diff.js +44 -24
  75. package/dist/sdk/coding-agent/core/tools/edit.d.ts +6 -0
  76. package/dist/sdk/coding-agent/core/tools/edit.d.ts.map +1 -1
  77. package/dist/sdk/coding-agent/core/tools/edit.js +31 -5
  78. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts +9 -3
  79. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts.map +1 -1
  80. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.js +42 -3
  81. package/dist/sdk/coding-agent/core/tools/read.d.ts.map +1 -1
  82. package/dist/sdk/coding-agent/core/tools/read.js +79 -47
  83. package/dist/sdk/coding-agent/core/tools/truncate.d.ts +4 -2
  84. package/dist/sdk/coding-agent/core/tools/truncate.d.ts.map +1 -1
  85. package/dist/sdk/coding-agent/core/tools/truncate.js +43 -23
  86. package/dist/sdk/coding-agent/core/tools/write.d.ts +5 -8
  87. package/dist/sdk/coding-agent/core/tools/write.d.ts.map +1 -1
  88. package/dist/sdk/coding-agent/core/tools/write.js +29 -11
  89. 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 ops.writeFile(absolutePath, finalContent);
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
- * Serialize file mutation operations targeting the same file.
3
- * Operations for different files still run in parallel.
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":"AAcA;;;GAGG;AACH,wBAAsB,qBAAqB,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAoBjG"}
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
- * Serialize file mutation operations targeting the same file.
15
- * Operations for different files still run in parallel.
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 key = getMutationQueueKey(filePath);
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,CA+IhE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAEnG"}
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
- const textContent = buffer.toString("utf-8");
203
- const allLines = textContent.split("\n");
204
- const totalFileLines = allLines.length;
205
- // Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.
206
- const startLine = offset ? Math.max(0, offset - 1) : 0;
207
- const startLineDisplay = startLine + 1;
208
- // Check if offset is out of bounds.
209
- if (startLine >= allLines.length) {
210
- throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
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
- let selectedContent;
213
- let userLimitedLines;
214
- // If limit is specified by the user, honor it first. Otherwise truncateHead decides.
215
- if (limit !== undefined) {
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
- selectedContent = allLines.slice(startLine).join("\n");
222
- }
223
- // Apply truncation, respecting both line and byte limits.
224
- const truncation = truncateHead(selectedContent);
225
- let outputText;
226
- if (truncation.firstLineExceedsLimit) {
227
- // First line alone exceeds the byte limit. Point the model at a bash fallback.
228
- const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
229
- outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
230
- details = { truncation };
231
- }
232
- else if (truncation.truncated) {
233
- // Truncation occurred. Build an actionable continuation notice.
234
- const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
235
- const nextOffset = endLineDisplay + 1;
236
- outputText = truncation.content;
237
- if (truncation.truncatedBy === "lines") {
238
- outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
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
- outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
252
+ selectedContent = allLines.slice(startLine).join("\n");
242
253
  }
243
- details = { truncation };
244
- }
245
- else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
246
- // User-specified limit stopped early, but the file still has more content.
247
- const remaining = allLines.length - (startLine + userLimitedLines);
248
- const nextOffset = startLine + userLimitedLines + 1;
249
- outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
250
- }
251
- else {
252
- // No truncation and no remaining user-limited content.
253
- outputText = truncation.content;
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
- * Never returns partial lines. If first line exceeds byte limit,
52
- * returns empty content with firstLineExceedsLimit=true.
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;;;;;;GAMG;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;AAuBD;;;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"}
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
- * Never returns partial lines. If first line exceeds byte limit,
42
- * returns empty content with firstLineExceedsLimit=true.
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
- // Check if first line alone exceeds byte limit
67
- const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
68
- if (firstLineBytes > maxBytes) {
69
- return {
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 lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
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: false,
112
- firstLineExceedsLimit: false,
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
- /** Write content to a file */
15
- writeFile: (absolutePath: string, content: string) => Promise<void>;
16
- /** Create directory recursively */
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;AAM7D,QAAA,MAAM,WAAW;;;EAGf,CAAC;AAEH,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,OAAO,WAAW,CAAC,CAAC;AAExD;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC/B,8BAA8B;IAC9B,SAAS,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,mCAAmC;IACnC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACtC;AAOD,MAAM,WAAW,gBAAgB;IAChC,oEAAoE;IACpE,UAAU,CAAC,EAAE,eAAe,CAAC;CAC7B;AA2DD,wBAAgB,yBAAyB,CACxC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,gBAAgB,GACxB,cAAc,CAAC,OAAO,WAAW,EAAE,SAAS,CAAC,CA2D/C;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,SAAS,CAAC,OAAO,WAAW,CAAC,CAEtG"}
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, writeFile as fsWriteFile } from "fs/promises";
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({ description: "Path to the file to write (relative or absolute)" }),
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(new Error("Operation aborted"));
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
- // Write the file contents.
93
- await ops.writeFile(absolutePath, content);
94
- if (aborted)
95
- return;
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
- { type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` },
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.126",
3
+ "version": "0.9.128",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,