pi-hashline-edit-pro 1.0.5 → 1.0.7

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/README.md CHANGED
@@ -62,6 +62,8 @@ Optional parameters:
62
62
 
63
63
  Paged output ends with a continuation hint, e.g. `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
64
64
 
65
+ Lines up to 200KB are displayed in full; larger lines are replaced by a marker with a bash inspection hint (`sed -n 'Np' <path> | head -c 204800`) since hash anchors require full lines.
66
+
65
67
  Edge cases:
66
68
 
67
69
  - **Images** (JPEG, PNG, GIF, WebP) are passed through as visual attachments and don't participate in the hashline protocol.
@@ -93,7 +95,7 @@ Exactly one edit per call, with `hash_range_inclusive` and `content_lines` at th
93
95
  Behavior:
94
96
 
95
97
  - **Validation before any file I/O.** Unknown fields, missing fields, wrong types, and malformed anchors are rejected with `[E_BAD_SHAPE]` / `[E_BAD_REF]`. The edit applies against the pre-edit snapshot, so all hashes in the request come from one consistent file state.
96
- - **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[E_BAD_SHAPE]` / `[E_LEGACY_SHAPE]`; the error tells you to send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
98
+ - **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[E_LEGACY_SHAPE]`; the error tells you to send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
97
99
  - **Autocorrections** (all accompanied by a warning unless noted):
98
100
  - A `HASH│` prefix accidentally left on a `content_lines` entry is stripped.
99
101
  - Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `content_lines` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
@@ -120,6 +122,7 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
120
122
 
121
123
  - After `replace` / `undo_last_replace`, the block covers the changed span plus 2 lines of context above and below — the rest of the file keeps its anchors from the persistent store.
122
124
  - After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint — use `read` with `offset` to continue.
125
+ - Auto-read keeps a 50KB display budget: lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
123
126
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
124
127
  - If the auto-read itself fails (e.g. the file was deleted between the operation and the read), a short `--- Auto-read failed: ... ---` notice is appended instead of the anchor block, so the model knows the anchors are missing.
125
128
 
@@ -129,6 +132,7 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
129
132
 
130
133
  - History is per-file and single-level: only the most recent replace can be reverted.
131
134
  - History is persisted in the hash store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) and survives session restarts; a failed `write` does not clear it.
135
+ - **Undo is a precondition, not a convenience.** The undo record is persisted *before* the edit is written; if it cannot be persisted, the `replace` is refused with `[E_UNDO_UNAVAILABLE]` and the file is not touched, so every applied edit is undoable. If the file write itself then fails, the previous undo record is restored, so a refused edit never destroys earlier undo history.
132
136
  - A successful `write` clears the history for that file.
133
137
  - Call `read` after an undo to get fresh anchors for follow-up edits.
134
138
  - **Safety guard.** If the file was modified or deleted since the last replace, `undo_last_replace` refuses with `[E_UNDO_STALE]` rather than overwriting those changes.
@@ -157,13 +161,14 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
157
161
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
158
162
  | `[E_INVALID_PATCH]` | A `content_lines` entry is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
159
163
  | `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
160
- | `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
164
+ | `[E_LEGACY_SHAPE]` | The request uses an unsupported dialect: `oldText`/`newText` fields or a `changes` array. |
161
165
  | `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
162
166
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
163
167
  | `[E_NOT_FOUND]` | The path does not exist. |
164
168
  | `[E_ACCESS]` | The file is not readable or writable. |
165
169
  | `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
166
170
  | `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
171
+ | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
167
172
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
168
173
 
169
174
  ## Hashing
@@ -182,6 +187,14 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
182
187
  - **Atomic and ordered writes.** Files are written via temp-file-then-rename; symlink chains are resolved so the target is updated without replacing the symlink; hard-linked files are updated in place; concurrent edits to the same underlying file serialize through a per-target mutation queue.
183
188
  - **One edit per call.** The request shape stays `{path, hash_range_inclusive, content_lines}` from schema through validation to application; there is no batching dialect.
184
189
 
190
+ ## Troubleshooting
191
+
192
+ - **Stale anchors.** `[E_STALE_ANCHOR]` / `[E_AMBIGUOUS_ANCHOR]` mean the file changed since the anchors were read, or an earlier `read` never happened. Call `read` for fresh anchors and retry.
193
+ - **Reset the hash store.** Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
194
+ - **Corrupt store.** If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` (plus `-wal`/`-shm` variants) and rebuilt automatically; the quarantined files can be deleted once a healthy store exists.
195
+ - **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted.
196
+ - **`[E_UNDO_UNAVAILABLE]`.** The edit was refused because the undo record could not be written — check disk space and that the config directory is writable, then retry.
197
+
185
198
  ## Development
186
199
 
187
200
  Requires [Node.js](https://nodejs.org) ≥ 22.13 and npm.
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
2
3
  import { initHasher } from "./src/hashline";
3
4
  import { regReplace } from "./src/replace";
4
5
  import { regReplaceUndo, clearUndo } from "./src/replace-undo";
@@ -98,6 +99,7 @@ export default function (pi: ExtensionAPI): void {
98
99
  { offset, limit },
99
100
  fileHashes,
100
101
  absolutePath,
102
+ DEFAULT_MAX_BYTES,
101
103
  );
102
104
 
103
105
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
6
6
  "main": "index.ts",
@@ -47,7 +47,7 @@
47
47
  "scripts": {
48
48
  "test": "vitest run",
49
49
  "test:watch": "vitest",
50
- "test:coverage": "vitest run --coverage",
50
+ "test:coverage": "vitest run --coverage --coverage.thresholds.lines=90 --coverage.thresholds.statements=90 --coverage.thresholds.functions=85 --coverage.thresholds.branches=80",
51
51
  "lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.ts\"",
52
52
  "typecheck": "tsc --noEmit"
53
53
  },
package/src/constants.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export const AUTO_READ_MAX = 2000;
2
2
  export const SNIFF_BYTES = 8192;
3
3
  export const MAX_BYTES = 100 * 1024 * 1024;
4
+ export const MAX_READ_LINE_BYTES = 200 * 1024;
4
5
 
5
6
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
6
7
  export const HASH_STORE_VERSION = 4;
@@ -143,6 +143,7 @@ export async function lineHashes(
143
143
  store?: HashStore,
144
144
  persist?: boolean,
145
145
  ): Promise<string[]> {
146
+ await initHasher();
146
147
  if (!path) {
147
148
  return _lineHashesPure(content);
148
149
  }
@@ -233,6 +234,16 @@ function mapStableHashes(
233
234
  if (idx !== undefined) removedIndexes.add(idx);
234
235
  }
235
236
 
237
+ let spanStart = oldLines.length;
238
+ let spanEnd = -1;
239
+ for (const idx of removedIndexes) {
240
+ if (idx < spanStart) spanStart = idx;
241
+ if (idx > spanEnd) spanEnd = idx;
242
+ }
243
+ const spanLen = spanEnd >= spanStart ? spanEnd - spanStart + 1 : 0;
244
+ const replacementLen = newLines.length - oldLines.length + spanLen;
245
+ const shiftAfterSpan = spanEnd >= spanStart ? replacementLen - spanLen : 0;
246
+
236
247
  const survivors: { index: number; hash: string }[] = [];
237
248
  const removedEntries: { index: number; hash: string }[] = [];
238
249
  for (let i = 0; i < oldLines.length; i++) {
@@ -260,7 +271,8 @@ function mapStableHashes(
260
271
  for (const entry of survivors) {
261
272
  const candidates = newByContent.get(canon(oldLines[entry.index]!));
262
273
  if (!candidates || candidates.length === 0) continue;
263
- const pos = nearestNew(candidates, entry.index);
274
+ const target = entry.index > spanEnd ? entry.index + shiftAfterSpan : entry.index;
275
+ const pos = nearestNew(candidates, target);
264
276
  if (pos < 0) continue;
265
277
  const newIdx = candidates.splice(pos, 1)[0]!;
266
278
  newHashes[newIdx] = entry.hash;
@@ -9,7 +9,7 @@ let hasher: Hasher | null = null;
9
9
 
10
10
  export function getH(): Hasher {
11
11
  if (hasher) return hasher;
12
- throw new Error("xxhash-wasm not initialized yet. This should not happen.");
12
+ throw new Error("xxhash-wasm hasher not initialized; await initHasher() before calling hashline APIs.");
13
13
  }
14
14
 
15
15
  const hasherP: Promise<Hasher> = xxhash().then((h) => {
@@ -225,7 +225,7 @@ export function stripBarePrefixes(
225
225
  ? "none of the stripped hashes match current file lines"
226
226
  : `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
227
227
  warnings.push(
228
- `Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).`
228
+ `[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).`
229
229
  );
230
230
  return { ...edit, content_lines: contentLines };
231
231
  }
@@ -251,7 +251,7 @@ export function stripDiffPrefixes(
251
251
  if (stripped.length === 0) return edit;
252
252
  const locations = stripped.map((i) => `content_lines[${i}]`).join(", ");
253
253
  warnings.push(
254
- `Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
254
+ `[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
255
255
  );
256
256
  return { ...edit, content_lines: contentLines };
257
257
  }
@@ -276,7 +276,7 @@ export function swapReversedRanges(
276
276
  return edit;
277
277
  }
278
278
  warnings.push(
279
- `Autocorrected: hash_range_inclusive was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
279
+ `[E_BAD_OP] Autocorrected: hash_range_inclusive was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
280
280
  );
281
281
  return { ...edit, hash_range_inclusive: [endRef, startRef] as [Anchor, Anchor] };
282
282
  }
package/src/read.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type TruncationResult,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { Type } from "typebox";
9
+ import { MAX_READ_LINE_BYTES } from "./constants";
9
10
  import { loadFileKindAndText } from "./file-kind";
10
11
  import { readNormFile } from "./file-reader";
11
12
  import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
@@ -58,6 +59,7 @@ export async function fmtReadPreview(
58
59
  options: { offset?: number; limit?: number },
59
60
  precomputedHashes?: string[],
60
61
  path?: string,
62
+ maxLineBytes = MAX_READ_LINE_BYTES,
61
63
  ): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
62
64
  const allLines = visLines(text);
63
65
  const totalLines = allLines.length;
@@ -88,15 +90,42 @@ export async function fmtReadPreview(
88
90
  const allHashes = precomputedHashes ?? await (path ? lineHashes(text, path) : lineHashes(text));
89
91
  const selectedHashes = allHashes.slice(startLine - 1, endIdx);
90
92
  const formatted = fmtRegion(selectedHashes, selected);
91
-
92
- const truncation = truncateHead(formatted);
93
- if (truncation.firstLineExceedsLimit) {
93
+ const maxBytes = maxLineBytes;
94
+ const rowSizes = selected.map((line, index) => ({
95
+ lineNumber: startLine + index,
96
+ bytes: Buffer.byteLength(`${selectedHashes[index]}${HASH_SEP}${line}`, "utf-8"),
97
+ }));
98
+ if (rowSizes.some((row) => row.bytes > maxBytes)) {
99
+ const oversized = rowSizes.filter((row) => row.bytes > maxBytes);
100
+ const rows = rowSizes.map((row, index) =>
101
+ row.bytes > maxBytes
102
+ ? `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`
103
+ : fmtRegion([selectedHashes[index]!], [selected[index]!]),
104
+ );
105
+ const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes });
106
+ const shownRows = rowSizes.filter((row) => row.bytes <= maxBytes);
107
+ const lastShownLine = shownRows.at(-1)?.lineNumber ?? startLine - 1;
108
+ const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
109
+ const verb = oversized.length === 1 ? "exceeds" : "exceed";
110
+ const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
111
+ const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown because hashline anchors require full lines. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}]`;
112
+ let preview = skippedTruncation.content;
113
+ let nextOffset: number | undefined;
114
+ if (shownRows.length > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
115
+ nextOffset = lastShownLine + 1;
116
+ preview += `\n\n${warning}\n${formatPaginationHint(startLine, lastShownLine, totalLines, nextOffset, skippedTruncation.truncated ? skippedTruncation.maxBytes : undefined)}`;
117
+ } else {
118
+ preview += `\n\n${warning}`;
119
+ }
94
120
  return {
95
- text: `[Line ${startLine} exceeds ${formatSize(truncation.maxBytes)}. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`,
96
- truncation,
121
+ text: preview,
122
+ truncation: skippedTruncation.truncated ? skippedTruncation : undefined,
123
+ ...(nextOffset !== undefined ? { nextOffset } : {}),
97
124
  };
98
125
  }
99
126
 
127
+ const truncation = truncateHead(formatted, { maxBytes });
128
+
100
129
  let preview = truncation.content;
101
130
  let nextOffset: number | undefined;
102
131
  if (truncation.truncated) {
@@ -16,6 +16,7 @@ export type RRState = {
16
16
  argsKey?: string;
17
17
  preview?: RPreview;
18
18
  previewGeneration?: number;
19
+ previewTimer?: ReturnType<typeof setTimeout>;
19
20
  };
20
21
 
21
22
  export function getPreviewInput(
@@ -2,7 +2,7 @@ import { readFile } from "fs/promises";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
- import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo } from "./hash-store";
5
+ import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
6
6
  import { contentChecksum } from "./hashline/hasher";
7
7
  import { resolveTarget, writeAtomic } from "./fs-write";
8
8
  import { toCwd } from "./paths";
@@ -19,9 +19,14 @@ export interface UndoEntry {
19
19
  resultContent: string;
20
20
  }
21
21
 
22
- export async function saveUndo(path: string, entry: UndoEntry): Promise<boolean> {
22
+ export async function saveUndo(
23
+ path: string,
24
+ entry: UndoEntry,
25
+ ): Promise<{ persisted: boolean; restore: () => Promise<void> }> {
26
+ let previous: UndoRecord | undefined;
23
27
  try {
24
28
  const store = await loadHashStore();
29
+ previous = getUndoEntry(store, path);
25
30
  upsertUndo(store, path, {
26
31
  content: entry.content,
27
32
  bom: entry.bom,
@@ -29,11 +34,22 @@ export async function saveUndo(path: string, entry: UndoEntry): Promise<boolean>
29
34
  hashes: entry.hashes,
30
35
  resultContent: entry.resultContent,
31
36
  });
32
- return true;
33
37
  } catch (error) {
34
38
  console.error("Failed to persist undo entry:", error);
35
- return false;
39
+ return { persisted: false, restore: async () => undefined };
36
40
  }
41
+ return {
42
+ persisted: true,
43
+ restore: async () => {
44
+ try {
45
+ const store = await loadHashStore();
46
+ if (previous) upsertUndo(store, path, previous);
47
+ else deleteUndo(store, path);
48
+ } catch (error) {
49
+ console.error("Failed to restore previous undo entry:", error);
50
+ }
51
+ },
52
+ };
37
53
  }
38
54
 
39
55
  export async function getUndo(path: string): Promise<UndoEntry | undefined> {
@@ -101,16 +117,15 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
101
117
  }
102
118
 
103
119
  return withFileMutationQueue(mutationTargetPath, async () => {
104
- let currentNormalized: string | undefined;
120
+ let currentRaw: string | undefined;
105
121
  try {
106
- const currentRaw = await readFile(mutationTargetPath, "utf-8");
107
- const { text: currentStripped } = stripBOM(currentRaw);
108
- currentNormalized = toLF(currentStripped);
122
+ currentRaw = await readFile(mutationTargetPath, "utf-8");
109
123
  } catch (error) {
110
124
  if (errCode(error) !== "ENOENT") throw error;
111
125
  }
112
126
 
113
- if (currentNormalized === undefined) {
127
+ if (currentRaw === undefined) {
128
+ await clearUndo(mutationTargetPath);
114
129
  return {
115
130
  content: [
116
131
  {
@@ -122,7 +137,8 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
122
137
  details: {},
123
138
  };
124
139
  }
125
- if (currentNormalized !== undo.resultContent) {
140
+ if (currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)) {
141
+ await clearUndo(mutationTargetPath);
126
142
  return {
127
143
  content: [
128
144
  {
@@ -135,6 +151,8 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
135
151
  };
136
152
  }
137
153
 
154
+ const { text: currentStripped } = stripBOM(currentRaw);
155
+ const currentNormalized = toLF(currentStripped);
138
156
  const diffResult = genDiff(undo.content, currentNormalized, 0);
139
157
  const linesAddedByReplace = cntDiff(diffResult.diff, "+");
140
158
  const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
package/src/replace.ts CHANGED
@@ -99,9 +99,11 @@ interface PipelineResult {
99
99
  totalRemovedLines: number;
100
100
  }
101
101
 
102
+ const PREVIEW_DEBOUNCE_MS = 150;
103
+
102
104
  const ROOT_KS = new Set(["path", "content_lines", "hash_range_inclusive"]);
103
105
 
104
- const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"];
106
+ const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines", "changes"];
105
107
 
106
108
  export function assertNoLegacyKeys(request: unknown): void {
107
109
  if (!isRec(request)) return;
@@ -251,11 +253,6 @@ export async function compPreview(
251
253
  ): Promise<RPreview> {
252
254
  try {
253
255
  const normalized = normReq(request);
254
- if (isRec(request) && Array.isArray(request.changes)) {
255
- return {
256
- error: `[E_BAD_SHAPE] The replace tool does not accept a "changes" array. Send hash_range_inclusive and content_lines at the top level (one edit per call).`
257
- };
258
- }
259
256
  assertReq(normalized);
260
257
  const { path, originalNormalized, result, resultHashes } = await execPipeline(
261
258
  normalized,
@@ -323,12 +320,20 @@ export function buildToolDef(): ToolDef {
323
320
  renderShell: "default",
324
321
  renderCall(args, theme, context) {
325
322
  const previewInput = getPreviewInput(args);
323
+ const cancelPendingPreview = () => {
324
+ if (context.state.previewTimer) {
325
+ clearTimeout(context.state.previewTimer);
326
+ context.state.previewTimer = undefined;
327
+ }
328
+ };
326
329
  if (context.executionStarted) {
330
+ cancelPendingPreview();
327
331
  context.state.argsKey = undefined;
328
332
  context.state.preview = undefined;
329
333
  context.state.previewGeneration =
330
334
  (context.state.previewGeneration ?? 0) + 1;
331
335
  } else if (!context.argsComplete || !previewInput) {
336
+ cancelPendingPreview();
332
337
  context.state.argsKey = undefined;
333
338
  context.state.preview = undefined;
334
339
  context.state.previewGeneration =
@@ -336,31 +341,35 @@ export function buildToolDef(): ToolDef {
336
341
  } else {
337
342
  const argsKey = JSON.stringify(previewInput);
338
343
  if (context.state.argsKey !== argsKey) {
344
+ cancelPendingPreview();
339
345
  context.state.argsKey = argsKey;
340
346
  context.state.preview = undefined;
341
347
  const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
342
348
  context.state.previewGeneration = previewGeneration;
343
- compPreview(args, context.cwd)
344
- .then((preview) => {
345
- if (
346
- context.state.argsKey === argsKey &&
347
- context.state.previewGeneration === previewGeneration
348
- ) {
349
- context.state.preview = preview;
350
- context.invalidate();
351
- }
352
- })
353
- .catch((err: unknown) => {
354
- if (
355
- context.state.argsKey === argsKey &&
356
- context.state.previewGeneration === previewGeneration
357
- ) {
358
- context.state.preview = {
359
- error: err instanceof Error ? err.message : String(err),
360
- };
361
- context.invalidate();
362
- }
363
- });
349
+ context.state.previewTimer = setTimeout(() => {
350
+ context.state.previewTimer = undefined;
351
+ compPreview(args, context.cwd)
352
+ .then((preview) => {
353
+ if (
354
+ context.state.argsKey === argsKey &&
355
+ context.state.previewGeneration === previewGeneration
356
+ ) {
357
+ context.state.preview = preview;
358
+ context.invalidate();
359
+ }
360
+ })
361
+ .catch((err: unknown) => {
362
+ if (
363
+ context.state.argsKey === argsKey &&
364
+ context.state.previewGeneration === previewGeneration
365
+ ) {
366
+ context.state.preview = {
367
+ error: err instanceof Error ? err.message : String(err),
368
+ };
369
+ context.invalidate();
370
+ }
371
+ });
372
+ }, PREVIEW_DEBOUNCE_MS);
364
373
  }
365
374
  }
366
375
  const text =
@@ -389,6 +398,10 @@ export function buildToolDef(): ToolDef {
389
398
 
390
399
  const renderState = context.state as RRState | undefined;
391
400
  if (renderState) {
401
+ if (renderState.previewTimer) {
402
+ clearTimeout(renderState.previewTimer);
403
+ renderState.previewTimer = undefined;
404
+ }
392
405
  renderState.preview = undefined;
393
406
  renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
394
407
  }
@@ -410,8 +423,9 @@ export function buildToolDef(): ToolDef {
410
423
 
411
424
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
412
425
  const canonical = normReq(params);
426
+ assertReq(canonical);
413
427
 
414
- const normalizedParams = canonical as ReqParams;
428
+ const normalizedParams = canonical;
415
429
  const path = normalizedParams.path;
416
430
  const absolutePath = toCwd(path, ctx.cwd);
417
431
  const mutationTargetPath = await resolveTarget(absolutePath);
@@ -462,21 +476,27 @@ export function buildToolDef(): ToolDef {
462
476
  }
463
477
 
464
478
  abortIf(signal);
465
- await writeAtomic(
466
- absolutePath,
467
- bom + restoreEndings(result, originalEnding),
468
- );
469
- const undoPersisted = await saveUndo(mutationTargetPath, {
479
+ const undo = await saveUndo(mutationTargetPath, {
470
480
  content: originalNormalized,
471
481
  bom,
472
482
  originalEnding,
473
483
  hashes: originalHashes,
474
484
  resultContent: result,
475
485
  });
476
- if (!undoPersisted) {
477
- warnings.push(
478
- "Undo history could not be persisted; undo_last_replace will not be available for this edit.",
486
+ if (!undo.persisted) {
487
+ throw new Error(
488
+ `[E_UNDO_UNAVAILABLE] Cannot persist undo history to the hash store; the edit was NOT applied and ${path} is unchanged. Retry the replace, or use write if the store cannot be recovered.`
489
+ );
490
+ }
491
+ try {
492
+ abortIf(signal);
493
+ await writeAtomic(
494
+ absolutePath,
495
+ bom + restoreEndings(result, originalEnding),
479
496
  );
497
+ } catch (error) {
498
+ await undo.restore();
499
+ throw error;
480
500
  }
481
501
  const updatedSnapshotId = (await fileSnap(absolutePath))
482
502
  .snapshotId;