pi-hashline-edit-pro 2.8.0 → 2.8.1

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
@@ -148,6 +148,7 @@ Notes:
148
148
  Notes:
149
149
  - Results are grouped per file under a `=== path ===` header; every shown row carries the anchor it would have in `read` output.
150
150
  - Directory searches skip `node_modules`, `.git`, `.tmp`, and `coverage`. Binary, image, and oversized files are skipped silently.
151
+ - Regex patterns with backreferences, nested quantifiers, quantified alternation, or multiple variable quantifiers are rejected with `[E_UNSAFE_REGEX]` before any files are scanned; use `literal: true` when regex behavior is unnecessary.
151
152
  - Output is capped at `limit` matched lines, 2000 rows, and 50KB of text (whichever comes first), with a hint naming the cap that cut results. A matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, so the relevant part of the hit stays visible; a context line over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable with `replace` (which always replaces the whole line). Directory scans stop after 4000 files with a hint; results may be incomplete.
152
153
  - `file_path` works as an alias for `path`.
153
154
  - Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
@@ -206,6 +207,8 @@ Anchors are unique by construction. If a line's base hash collides with an alrea
206
207
 
207
208
  Hashes live in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that keeps the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
208
209
 
210
+ On POSIX systems, the state directory is restricted to mode `0700` and the SQLite database plus its WAL/SHM sidecars to `0600`. The undo table contains the complete pre-edit and post-edit text for the latest edit to each file, so the store should still be treated as sensitive data.
211
+
209
212
  The store also keeps a per-file record of the hashes the model was last served (`read` rows, auto-read blocks, post-edit diff rows), pruned to the file's current hashes on every update so removed lines' hashes do not accumulate. `replace` verifies every line of the resolved range against that record before writing; a line whose hash is missing from the record means it either changed on disk after it was shown or was never shown, and the edit is refused with `[E_RANGE_STALE]`. A `write` clears the record, so edits after a write are verified against whatever the next `read` or auto-read block serves.
210
213
 
211
214
  Two guarantees make this safe even with duplicated content:
@@ -236,6 +239,8 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
236
239
  | `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
237
240
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
238
241
  | `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
242
+ | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
243
+ | `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
239
244
 
240
245
  ## Troubleshooting
241
246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.8.0",
3
+ "version": "2.8.1",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -39,8 +39,8 @@
39
39
  ]
40
40
  },
41
41
  "dependencies": {
42
- "diff": "^8.0.2",
43
- "file-type": "^21.3.0",
42
+ "diff": "^9.0.0",
43
+ "file-type": "^22.0.2",
44
44
  "typebox": "^1.3.7",
45
45
  "xxhash-wasm": "^1.1.0"
46
46
  },
@@ -63,12 +63,12 @@
63
63
  "devDependencies": {
64
64
  "@earendil-works/pi-coding-agent": "^0.84.0",
65
65
  "@eslint/js": "^10.0.1",
66
- "@types/node": "^24.0.0",
67
- "@vitest/coverage-v8": "^4.1.10",
68
- "eslint": "^10.7.0",
69
- "typescript": "^5.8.0",
70
- "typescript-eslint": "^8.65.0",
71
- "vitest": "^4.1.8"
66
+ "@types/node": "^24",
67
+ "@vitest/coverage-v8": "^4.1.11",
68
+ "eslint": "^10.9.1",
69
+ "typescript": "^5.9.3",
70
+ "typescript-eslint": "^8.68.0",
71
+ "vitest": "^4.1.11"
72
72
  },
73
73
  "allowScripts": {
74
74
  "@google/genai@1.52.0": true,
package/src/commit.ts CHANGED
@@ -5,7 +5,7 @@ import { saveUndo } from "./replace-undo";
5
5
  import { safeSnapId } from "./file-reader";
6
6
  import { writeAtomic } from "./fs-write";
7
7
  import { recordServedDiffSafe } from "./served";
8
- import { restoreEndings } from "./replace-diff";
8
+ import { restoreEndings } from "./normalize";
9
9
 
10
10
  export interface CommitMeta {
11
11
  path: string;
@@ -72,6 +72,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
72
72
  await writeAtomic(
73
73
  absolutePath,
74
74
  pipe.bom + restoreEndings(pipe.result, pipe.originalEnding),
75
+ pipe.identity,
75
76
  );
76
77
  } catch (error) {
77
78
  await undo.restore();
@@ -0,0 +1,45 @@
1
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2
+ import { resolveInCwd } from "./fs-write";
3
+ import { abortIf, makePrepareArguments } from "./utils";
4
+ import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
5
+ import type { ReplaceDetails } from "./replace";
6
+
7
+ export const editPrepare = makePrepareArguments();
8
+
9
+ export function editRenderResultWrapper(
10
+ result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
11
+ opts: { isPartial: boolean; expanded?: boolean } | boolean,
12
+ theme: FgT,
13
+ context: any,
14
+ ) {
15
+ return renderEditResult(result, opts, theme, context);
16
+ }
17
+
18
+ export function editRenderCallWrapper(
19
+ preview: (args: unknown, cwd: string) => Promise<RPreview>,
20
+ getInput?: (args: unknown) => { path?: string } | null,
21
+ toolName?: string,
22
+ ) {
23
+ return makeRenderCall(preview, { getInput, toolName });
24
+ }
25
+
26
+ export const editToolBase = {
27
+ prepareArguments: editPrepare,
28
+ executionMode: "sequential" as const,
29
+ renderShell: "default" as const,
30
+ };
31
+
32
+ export async function queuedEdit<T>(
33
+ path: string,
34
+ cwd: string,
35
+ signal: AbortSignal | undefined,
36
+ work: (absolute: string, resolved: string) => Promise<T>,
37
+ ): Promise<T> {
38
+ abortIf(signal);
39
+ const { absolute, resolved } = await resolveInCwd(path, cwd);
40
+ return withFileMutationQueue(resolved, async () => {
41
+ abortIf(signal);
42
+ return work(absolute, resolved);
43
+ });
44
+ }
45
+
package/src/file-kind.ts CHANGED
@@ -2,6 +2,7 @@ import { open as fsOpen, stat as fsStat } from "fs/promises";
2
2
  import { fileTypeFromBuffer } from "file-type";
3
3
  import { SNIFF_BYTES, MAX_BYTES } from "./constants";
4
4
  import { assertLineLimit, lineLimitMoreThanMessage } from "./utils";
5
+ import type { FileIdentity } from "./fs-write";
5
6
 
6
7
  const IMG_TYPES = new Set<string>([
7
8
  "image/bmp",
@@ -54,7 +55,7 @@ function looksLikeText(sample: Uint8Array): boolean {
54
55
  export type LFile =
55
56
  | { kind: "directory" }
56
57
  | { kind: "image"; mimeType: string }
57
- | { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
58
+ | { kind: "text"; text: string; identity?: FileIdentity; hadUtf8DecodeErrors?: true }
58
59
  | { kind: "binary"; description: string }
59
60
  | { kind: "too_large"; description: string };
60
61
 
@@ -87,6 +88,8 @@ export async function loadFileKindAndText(
87
88
 
88
89
  const fileHandle = await fsOpen(filePath, "r");
89
90
  try {
91
+ const openedStats = await fileHandle.stat();
92
+ const identity = { dev: openedStats.dev, ino: openedStats.ino };
90
93
  const buffer = Buffer.alloc(SNIFF_BYTES);
91
94
  const { bytesRead } = await fileHandle.read(
92
95
  buffer,
@@ -95,7 +98,7 @@ export async function loadFileKindAndText(
95
98
  0,
96
99
  );
97
100
  if (bytesRead === 0) {
98
- return { kind: "text", text: "" };
101
+ return { kind: "text", text: "", identity };
99
102
  }
100
103
 
101
104
  const sample = buffer.subarray(0, bytesRead);
@@ -179,6 +182,7 @@ export async function loadFileKindAndText(
179
182
  return {
180
183
  kind: "text",
181
184
  text,
185
+ identity,
182
186
  ...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
183
187
  };
184
188
  } finally {
@@ -3,9 +3,9 @@ import { stat } from "fs/promises";
3
3
  import { relative } from "path";
4
4
  import { lineHashes } from "./hashline";
5
5
  import { loadFileKindAndText, type LFile } from "./file-kind";
6
- import { resolveTarget } from "./fs-write";
6
+ import { resolveTarget, type FileIdentity } from "./fs-write";
7
7
  import { toCwd } from "./paths";
8
- import { detectEnding, toLF, stripBOM, type LineEnding } from "./replace-diff";
8
+ import { detectEnding, toLF, stripBOM, type LineEnding } from "./normalize";
9
9
  import { abortIf, errCode, assertLineLimit } from "./utils";
10
10
  import { valKind, valAccess } from "./validation";
11
11
  import type { HashStore } from "./hash-store";
@@ -16,6 +16,7 @@ export interface NormFile {
16
16
  originalEnding: LineEnding;
17
17
  fileHashes: string[];
18
18
  hadUtf8DecodeErrors: boolean;
19
+ identity: FileIdentity;
19
20
  }
20
21
 
21
22
  export type SnapInfo = {
@@ -94,12 +95,18 @@ export async function readNormFile(
94
95
  if (options?.maxLines !== undefined) assertLineLimit(normalized, path, options.maxLines);
95
96
 
96
97
  const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store, options?.noPersist !== true);
98
+ let identity = file.identity;
99
+ if (!identity) {
100
+ const { dev, ino } = await stat(resolvedPath);
101
+ identity = { dev, ino };
102
+ }
97
103
  return {
98
104
  absolutePath: resolvedPath,
99
105
  normalized,
100
106
  bom,
101
107
  originalEnding,
102
108
  fileHashes,
109
+ identity,
103
110
  hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
104
111
  };
105
112
  }
package/src/fs-write.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "crypto";
2
+ import { constants } from "fs";
2
3
  import {
3
- chmod,
4
4
  lstat,
5
5
  mkdir,
6
6
  open,
@@ -15,6 +15,22 @@ import { dirname, join, parse, resolve, sep } from "path";
15
15
  import { toCwd } from "./paths";
16
16
  import { errCode } from "./utils";
17
17
 
18
+ export interface FileIdentity {
19
+ dev: number;
20
+ ino: number;
21
+ }
22
+
23
+ function sameIdentity(
24
+ actual: Pick<Awaited<ReturnType<typeof stat>>, "dev" | "ino">,
25
+ expected: FileIdentity,
26
+ ): boolean {
27
+ return actual.dev === expected.dev && actual.ino === expected.ino;
28
+ }
29
+
30
+ function pathChanged(path: string): Error {
31
+ return new Error(`[E_PATH_CHANGED] Refusing to write ${path}: the target changed after it was read.`);
32
+ }
33
+
18
34
  export async function resolveTarget(path: string): Promise<string> {
19
35
  const absolutePath = resolve(path);
20
36
  const { root } = parse(absolutePath);
@@ -128,6 +144,7 @@ export async function resolveInCwd(path: string, cwd: string): Promise<{ absolut
128
144
  export async function writeAtomic(
129
145
  path: string,
130
146
  content: string,
147
+ expectedIdentity?: FileIdentity,
131
148
  ): Promise<void> {
132
149
  const targetPath = await resolveTarget(path);
133
150
 
@@ -140,19 +157,25 @@ export async function writeAtomic(
140
157
  }
141
158
  }
142
159
 
160
+ if (expectedIdentity && (!existingStats || !sameIdentity(existingStats, expectedIdentity))) {
161
+ throw pathChanged(path);
162
+ }
163
+
143
164
  if (existingStats && existingStats.nlink > 1) {
144
- await writeFile(targetPath, content, "utf-8");
145
- try {
146
- await chmod(targetPath, existingStats.mode & 0o7777);
147
- } catch {}
165
+ const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
166
+ const handle = await open(targetPath, constants.O_WRONLY | noFollow);
148
167
  try {
149
- const handle = await open(targetPath, "r");
168
+ const openedStats = await handle.stat();
169
+ if (!sameIdentity(openedStats, existingStats)) throw pathChanged(path);
170
+ await handle.writeFile(content, "utf-8");
171
+ await handle.truncate(Buffer.byteLength(content, "utf-8"));
150
172
  try {
151
- await handle.sync();
152
- } finally {
153
- await handle.close();
154
- }
155
- } catch {}
173
+ await handle.chmod(existingStats.mode & 0o7777);
174
+ } catch {}
175
+ await handle.sync();
176
+ } finally {
177
+ await handle.close();
178
+ }
156
179
  return;
157
180
  }
158
181
 
@@ -174,6 +197,14 @@ export async function writeAtomic(
174
197
  }
175
198
  try {
176
199
  await tempHandle.close();
200
+ try {
201
+ const finalStats = await lstat(targetPath);
202
+ if (!existingStats || finalStats.isSymbolicLink() || !sameIdentity(finalStats, existingStats)) {
203
+ throw pathChanged(path);
204
+ }
205
+ } catch (error) {
206
+ if (errCode(error) !== "ENOENT" || existingStats) throw error;
207
+ }
177
208
  await rename(tempPath, targetPath);
178
209
  await syncDir(dir);
179
210
  } catch (error: unknown) {
package/src/grep.ts CHANGED
@@ -47,6 +47,7 @@ export function assertGrepReq(request: unknown): asserts request is GrepReq {
47
47
  }
48
48
 
49
49
  function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
50
+ if (!literal) assertSafeRegex(pattern);
50
51
  const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
51
52
  try {
52
53
  return new RegExp(source, ignoreCase ? "ui" : "u");
@@ -55,6 +56,95 @@ function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): Reg
55
56
  }
56
57
  }
57
58
 
59
+ interface RegexGroupRisk {
60
+ hasQuantifier: boolean;
61
+ hasAlternation: boolean;
62
+ }
63
+
64
+ function unsafeRegex(pattern: string): never {
65
+ throw new Error(
66
+ `[E_UNSAFE_REGEX] Refusing potentially exponential regex: ${pattern}. Use literal: true or simplify the expression.`,
67
+ );
68
+ }
69
+
70
+ function assertSafeRegex(pattern: string): void {
71
+ if (pattern.length > 4096) unsafeRegex(pattern);
72
+
73
+ const groups: RegexGroupRisk[] = [];
74
+ let inClass = false;
75
+ let escaped = false;
76
+ let variableQuantifiers = 0;
77
+ let lastAtom: { groupRisky: boolean; quantified: boolean } | undefined;
78
+
79
+ for (let i = 0; i < pattern.length; i++) {
80
+ const ch = pattern[i]!;
81
+ if (escaped) {
82
+ if (!inClass && (/[1-9]/.test(ch) || (ch === "k" && pattern[i + 1] === "<"))) {
83
+ unsafeRegex(pattern);
84
+ }
85
+ escaped = false;
86
+ lastAtom = { groupRisky: false, quantified: false };
87
+ continue;
88
+ }
89
+ if (ch === "\\") {
90
+ escaped = true;
91
+ continue;
92
+ }
93
+ if (inClass) {
94
+ if (ch === "]") {
95
+ inClass = false;
96
+ lastAtom = { groupRisky: false, quantified: false };
97
+ }
98
+ continue;
99
+ }
100
+ if (ch === "[") {
101
+ inClass = true;
102
+ continue;
103
+ }
104
+ if (ch === "(") {
105
+ groups.push({ hasQuantifier: false, hasAlternation: false });
106
+ lastAtom = undefined;
107
+ continue;
108
+ }
109
+ if (ch === ")") {
110
+ const group = groups.pop();
111
+ if (group) {
112
+ lastAtom = {
113
+ groupRisky: group.hasQuantifier || group.hasAlternation,
114
+ quantified: false,
115
+ };
116
+ }
117
+ continue;
118
+ }
119
+ if (ch === "|") {
120
+ const group = groups.at(-1);
121
+ if (group) group.hasAlternation = true;
122
+ lastAtom = undefined;
123
+ continue;
124
+ }
125
+
126
+ let quantifierLength = 0;
127
+ if (ch === "*" || ch === "+" || ch === "?") {
128
+ quantifierLength = 1;
129
+ } else if (ch === "{") {
130
+ quantifierLength = /^\{\d+(?:,\d*)?\}/.exec(pattern.slice(i))?.[0].length ?? 0;
131
+ }
132
+ if (quantifierLength > 0 && lastAtom) {
133
+ if (ch === "?" && lastAtom.quantified) continue;
134
+ const variable = ch !== "{" || pattern.slice(i, i + quantifierLength).includes(",");
135
+ if (variable && ++variableQuantifiers > 1) unsafeRegex(pattern);
136
+ if (lastAtom.groupRisky) unsafeRegex(pattern);
137
+ const group = groups.at(-1);
138
+ if (group) group.hasQuantifier = true;
139
+ lastAtom.quantified = true;
140
+ i += quantifierLength - 1;
141
+ continue;
142
+ }
143
+
144
+ lastAtom = { groupRisky: false, quantified: false };
145
+ }
146
+ }
147
+
58
148
  function globToRegex(glob: string): RegExp {
59
149
  if (glob.startsWith("/")) glob = glob.slice(1);
60
150
  let source = "";
@@ -138,10 +228,12 @@ async function walkFiles(
138
228
  root: string,
139
229
  state: ScanState,
140
230
  onFile: (absPath: string) => Promise<void>,
231
+ signal?: AbortSignal,
141
232
  ): Promise<void> {
142
233
  const queue: string[] = [root];
143
234
  let head = 0;
144
235
  while (head < queue.length && !state.stopped) {
236
+ abortIf(signal);
145
237
  const dir = queue[head++]!;
146
238
  let entries;
147
239
  try {
@@ -150,8 +242,10 @@ async function walkFiles(
150
242
  continue;
151
243
  }
152
244
  entries.sort((a, b) => cmp(a.name, b.name));
153
- for (const entry of entries) {
245
+ for (let ei = 0; ei < entries.length; ei++) {
246
+ if ((ei & 127) === 0) abortIf(signal);
154
247
  if (state.stopped) break;
248
+ const entry = entries[ei]!;
155
249
  const full = join(dir, entry.name);
156
250
  if (entry.isDirectory()) {
157
251
  if (SKIP_DIRS.has(entry.name)) continue;
@@ -176,17 +270,20 @@ async function searchFile(
176
270
  globRegex: RegExp | undefined,
177
271
  context: number,
178
272
  maxMatches: number,
273
+ signal?: AbortSignal,
179
274
  ): Promise<FileHit | undefined> {
180
275
  const displayPath = relative(cwd, absPath).replace(/\\/g, "/");
181
276
  if (globRegex) {
182
277
  const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
183
278
  if (!globRegex.test(globPath) && !globRegex.test(displayPath)) return undefined;
184
279
  }
185
- const norm = await tryReadNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, noPersist: true });
280
+ const norm = await tryReadNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
186
281
  if (!norm) return undefined;
187
282
  const lines = visLines(norm.normalized);
188
283
  const matchLines: number[] = [];
189
284
  for (let i = 0; i < lines.length; i++) {
285
+ if ((i & 1023) === 0) abortIf(signal);
286
+ if (i !== 0 && (i & 4095) === 0) await new Promise<void>((r) => setImmediate(r));
190
287
  if (regex.test(lines[i]!)) matchLines.push(i);
191
288
  }
192
289
  if (matchLines.length === 0) return undefined;
@@ -306,7 +403,7 @@ export function regGrep(pi: ExtensionAPI): void {
306
403
  } else {
307
404
  await walkFiles(base, state, async (absPath) => {
308
405
  files.push(absPath);
309
- });
406
+ }, signal);
310
407
  files.sort(cmp);
311
408
  }
312
409
  const hits: FileHit[] = [];
@@ -324,7 +421,7 @@ export function regGrep(pi: ExtensionAPI): void {
324
421
  abortIf(signal);
325
422
  const absPath = files[f]!;
326
423
  if (countOnly) {
327
- const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, Number.MAX_SAFE_INTEGER);
424
+ const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, Number.MAX_SAFE_INTEGER, signal);
328
425
  if (!hit) continue;
329
426
  totalRows += hit.rows.length;
330
427
  for (const row of hit.rows) totalBytes += Buffer.byteLength(row, "utf-8") + 1;
@@ -342,7 +439,7 @@ export function regGrep(pi: ExtensionAPI): void {
342
439
  limitTruncated = true;
343
440
  break;
344
441
  }
345
- const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining);
442
+ const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining, signal);
346
443
  if (!hit) continue;
347
444
  const keptRows: string[] = [];
348
445
  const keptHashes: string[] = [];
@@ -0,0 +1,18 @@
1
+ export interface SnapshotCacheEntry {
2
+ checksum: string;
3
+ lineCount: number;
4
+ hashes: string[];
5
+ }
6
+
7
+ export const SNAPSHOT_CACHE_LIMIT = 256;
8
+
9
+ export const snapshotCache = new Map<string, SnapshotCacheEntry>();
10
+
11
+ export function cacheSnapshot(path: string, checksum: string, lineCount: number, hashes: string[]): void {
12
+ snapshotCache.delete(path);
13
+ snapshotCache.set(path, { checksum, lineCount, hashes: hashes.slice() });
14
+ if (snapshotCache.size > SNAPSHOT_CACHE_LIMIT) {
15
+ const oldest = snapshotCache.keys().next().value;
16
+ if (oldest !== undefined) snapshotCache.delete(oldest);
17
+ }
18
+ }
@@ -0,0 +1,48 @@
1
+ import { isBusyError } from "./validation";
2
+
3
+ const sleepSab = new Int32Array(new SharedArrayBuffer(4));
4
+
5
+ function sleepSync(ms: number): void {
6
+ Atomics.wait(sleepSab, 0, 0, ms);
7
+ }
8
+
9
+ const BUSY_RETRIES = 3;
10
+ const BUSY_RETRY_DELAY_MS = 50;
11
+
12
+ export function withBusyRetry<T>(fn: () => T): T {
13
+ let lastError: unknown;
14
+ for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
15
+ try {
16
+ return fn();
17
+ } catch (error) {
18
+ lastError = error;
19
+ if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
20
+ sleepSync(BUSY_RETRY_DELAY_MS * (1 << attempt));
21
+ }
22
+ }
23
+ throw lastError;
24
+ }
25
+
26
+ export async function withBusyRetryAsync<T>(fn: () => T): Promise<T> {
27
+ let lastError: unknown;
28
+ for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
29
+ try {
30
+ return fn();
31
+ } catch (error) {
32
+ lastError = error;
33
+ if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
34
+ await new Promise<void>((r) => setTimeout(r, BUSY_RETRY_DELAY_MS * (1 << attempt)));
35
+ }
36
+ }
37
+ throw lastError;
38
+ }
39
+
40
+ export function retriedWrite(stmt: { run(...params: (string | number)[]): unknown }): (...params: (string | number)[]) => void {
41
+ return (...params) => {
42
+ withBusyRetry(() => { stmt.run(...params); });
43
+ };
44
+ }
45
+
46
+ export async function openDbWithBusyRetryAsync<T>(fn: () => T): Promise<T> {
47
+ return withBusyRetryAsync(fn);
48
+ }
@@ -0,0 +1,62 @@
1
+ import { HASH_RE } from "../hashline/alphabet";
2
+
3
+ export function isValidHashList(value: unknown): value is string[] {
4
+ if (!Array.isArray(value)) return false;
5
+ for (const hash of value) {
6
+ if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
7
+ }
8
+ if (new Set(value).size !== value.length) return false;
9
+ return true;
10
+ }
11
+
12
+ export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
13
+ let parsed: unknown;
14
+ try {
15
+ parsed = JSON.parse(raw);
16
+ } catch (error) {
17
+ console.error(`[parseHashList]${context ? ` ${context}:` : ""} failed to parse stored hashes JSON:`, error);
18
+ onInvalid();
19
+ return undefined;
20
+ }
21
+ if (!isValidHashList(parsed)) {
22
+ console.error(`[parseHashList]${context ? ` ${context}:` : ""} stored hashes did not pass validation:`, Array.isArray(parsed) ? `length=${parsed.length} sample=${JSON.stringify(parsed.slice(0, 3))}` : (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
23
+ onInvalid();
24
+ return undefined;
25
+ }
26
+ return parsed;
27
+ }
28
+
29
+ export function parseStoredHashes(row: Record<string, unknown> | undefined, onInvalid: () => void): string[] | undefined {
30
+ if (!row) return undefined;
31
+ return parseHashList(row.hashes as string, onInvalid);
32
+ }
33
+
34
+ export function isValidSnapshot(value: unknown): value is { content: string; hashes: string[] } {
35
+ if (typeof value !== "object" || value === null) return false;
36
+ const v = value as Record<string, unknown>;
37
+ if (typeof v.content !== "string") return false;
38
+ return isValidHashList(v.hashes);
39
+ }
40
+
41
+ export function isCorruptionError(error: unknown): boolean {
42
+ if (error && typeof error === "object") {
43
+ const errcode = (error as { errcode?: unknown }).errcode;
44
+ if (typeof errcode === "number") {
45
+ return errcode === 11 || errcode === 24 || errcode === 26;
46
+ }
47
+ const code = (error as { code?: unknown }).code;
48
+ if (typeof code === "string" && /NOTADB|CORRUPT/.test(code)) return true;
49
+ }
50
+ return (
51
+ error instanceof Error &&
52
+ /corrupt|not a database|malformed|database disk image/i.test(error.message)
53
+ );
54
+ }
55
+
56
+ export function isBusyError(error: unknown): boolean {
57
+ if (error && typeof error === "object") {
58
+ const errcode = (error as { errcode?: unknown }).errcode;
59
+ if (typeof errcode === "number") return errcode === 5 || errcode === 6;
60
+ }
61
+ return error instanceof Error && /busy|locked/i.test(error.message);
62
+ }
package/src/hash-store.ts CHANGED
@@ -1,11 +1,29 @@
1
1
  import { existsSync } from "fs";
2
- import { readFile, rename, mkdir, stat } from "fs/promises";
2
+ import { chmod, readFile, rename, mkdir, stat } from "fs/promises";
3
3
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
4
4
  import { errCode, isRec, splitLines } from "./utils";
5
5
  import { initHasher, contentChecksum } from "./hashline/hasher";
6
- import { HASH_RE } from "./hashline/alphabet";
7
6
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
8
-
7
+ import {
8
+ isValidHashList,
9
+ parseStoredHashes,
10
+ isValidSnapshot,
11
+ isCorruptionError,
12
+ parseHashList,
13
+ } from "./hash-store/validation";
14
+ import {
15
+ withBusyRetry,
16
+ retriedWrite,
17
+ openDbWithBusyRetryAsync,
18
+ } from "./hash-store/retry";
19
+ import {
20
+ snapshotCache,
21
+ cacheSnapshot,
22
+ SNAPSHOT_CACHE_LIMIT,
23
+ } from "./hash-store/cache";
24
+
25
+ export { isValidHashList, parseHashList, parseStoredHashes, isCorruptionError };
26
+ export { SNAPSHOT_CACHE_LIMIT };
9
27
  export const STORE_NOT_OPEN_MESSAGE = "Hash store is not open; transactional update aborted";
10
28
 
11
29
  type SqlParams = (string | number)[];
@@ -92,133 +110,10 @@ export interface UndoRecord {
92
110
  resultContent: string;
93
111
  }
94
112
 
95
- interface LegacySnapshot {
96
- content: string;
97
- hashes: string[];
98
- }
99
-
100
- export function isValidHashList(value: unknown): value is string[] {
101
- if (!Array.isArray(value)) return false;
102
- for (const hash of value) {
103
- if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
104
- }
105
- if (new Set(value).size !== value.length) return false;
106
- return true;
107
- }
108
-
109
- export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
110
- let parsed: unknown;
111
- try {
112
- parsed = JSON.parse(raw);
113
- } catch (error) {
114
- console.error(`[parseHashList]${context ? ` ${context}:` : ""} failed to parse stored hashes JSON:`, error);
115
- onInvalid();
116
- return undefined;
117
- }
118
- if (!isValidHashList(parsed)) {
119
- console.error(`[parseHashList]${context ? ` ${context}:` : ""} stored hashes did not pass validation:`, Array.isArray(parsed) ? `length=${parsed.length} sample=${JSON.stringify(parsed.slice(0, 3))}` : (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
120
- onInvalid();
121
- return undefined;
122
- }
123
- return parsed;
124
- }
125
- export function parseStoredHashes(
126
- row: Record<string, unknown> | undefined,
127
- onInvalid: () => void,
128
- ): string[] | undefined {
129
- if (!row) return undefined;
130
- return parseHashList(row.hashes as string, onInvalid);
131
- }
132
-
133
- function isValidSnapshot(value: unknown): value is LegacySnapshot {
134
- if (typeof value !== "object" || value === null) return false;
135
- const v = value as Record<string, unknown>;
136
- if (typeof v.content !== "string") return false;
137
- return isValidHashList(v.hashes);
138
- }
139
-
140
- export function isCorruptionError(error: unknown): boolean {
141
- if (error && typeof error === "object") {
142
- const errcode = (error as { errcode?: unknown }).errcode;
143
- if (typeof errcode === "number") {
144
- return errcode === 11 || errcode === 24 || errcode === 26;
145
- }
146
- const code = (error as { code?: unknown }).code;
147
- if (typeof code === "string" && /NOTADB|CORRUPT/.test(code)) return true;
148
- }
149
- return (
150
- error instanceof Error &&
151
- /corrupt|not a database|malformed|database disk image/i.test(error.message)
152
- );
153
- }
154
-
155
- function isBusyError(error: unknown): boolean {
156
- if (error && typeof error === "object") {
157
- const errcode = (error as { errcode?: unknown }).errcode;
158
- if (typeof errcode === "number") return errcode === 5 || errcode === 6;
159
- }
160
- return error instanceof Error && /busy|locked/i.test(error.message);
161
- }
162
-
163
- const sleepSab = new Int32Array(new SharedArrayBuffer(4));
164
-
165
- function sleepSync(ms: number): void {
166
- Atomics.wait(sleepSab, 0, 0, ms);
167
- }
168
-
169
- const BUSY_RETRIES = 3;
170
- const BUSY_RETRY_DELAY_MS = 50;
171
-
172
- function withBusyRetry<T>(fn: () => T): T {
173
- let lastError: unknown;
174
- for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
175
- try {
176
- return fn();
177
- } catch (error) {
178
- lastError = error;
179
- if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
180
- sleepSync(BUSY_RETRY_DELAY_MS * (1 << attempt));
181
- }
182
- }
183
- throw lastError;
184
- }
185
-
186
- async function withBusyRetryAsync<T>(fn: () => T): Promise<T> {
187
- let lastError: unknown;
188
- for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
189
- try {
190
- return fn();
191
- } catch (error) {
192
- lastError = error;
193
- if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
194
- await new Promise<void>((r) => setTimeout(r, BUSY_RETRY_DELAY_MS * (1 << attempt)));
195
- }
196
- }
197
- throw lastError;
198
- }
199
-
200
- async function openDbWithBusyRetryAsync(storePath: string): Promise<{ db: RawDb; stmts: Prepared }> {
201
- return withBusyRetryAsync(() => openDb(storePath));
202
- }
203
-
204
- function retriedWrite(
205
- stmt: { run(...params: SqlParams): unknown },
206
- ): (...params: SqlParams) => void {
207
- return (...params) => {
208
- withBusyRetry(() => { stmt.run(...params); });
209
- };
210
- }
211
-
212
113
  let cachedDb: { path: string; db: RawDb; stmts: Prepared } | null = null;
213
114
  let opening: { path: string; promise: Promise<HashStore> } | null = null;
214
115
  let exitHandlerRegistered = false;
215
- interface SnapshotCacheEntry {
216
- checksum: string;
217
- lineCount: number;
218
- hashes: string[];
219
- }
220
- const snapshotCache = new Map<string, SnapshotCacheEntry>();
221
- export const SNAPSHOT_CACHE_LIMIT = 256;
116
+
222
117
  function openDb(storePath: string): { db: RawDb; stmts: Prepared } {
223
118
  const db = openDbFn(storePath);
224
119
  try {
@@ -231,9 +126,7 @@ function openDb(storePath: string): { db: RawDb; stmts: Prepared } {
231
126
  }
232
127
  }
233
128
 
234
- function buildStore(
235
- db: RawDb,
236
- ): { db: RawDb; stmts: Prepared } {
129
+ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
237
130
  db.exec("PRAGMA journal_mode = WAL");
238
131
  db.exec("PRAGMA synchronous = NORMAL");
239
132
  db.exec(
@@ -351,30 +244,45 @@ function shutdownDb(db: RawDb): void {
351
244
  }
352
245
 
353
246
  async function openStore(storePath: string): Promise<HashStore> {
354
- shutdownHashStore();
355
-
247
+ if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
248
+ return { stmts: cachedDb.stmts, engine: sqliteEngine };
249
+ }
250
+ if (cachedDb) shutdownHashStore();
356
251
  await initHasher();
357
- await mkdir(hashStoreDir(), { recursive: true });
252
+ await mkdir(hashStoreDir(), { recursive: true, mode: 0o700 });
253
+ if (process.platform !== "win32") {
254
+ await chmod(hashStoreDir(), 0o700);
255
+ }
358
256
 
359
257
  let existed = existsSync(storePath);
360
258
  let opened: { db: RawDb; stmts: Prepared };
361
259
  try {
362
- opened = await openDbWithBusyRetryAsync(storePath);
260
+ opened = await openDbWithBusyRetryAsync(() => openDb(storePath));
363
261
  } catch (error) {
364
262
  if (!isCorruptionError(error)) throw error;
365
263
  console.error("Hash store failed to open, rebuilding:", error);
366
264
  await quarantineStore(storePath);
367
265
  existed = false;
368
- opened = await openDbWithBusyRetryAsync(storePath);
266
+ opened = await openDbWithBusyRetryAsync(() => openDb(storePath));
369
267
  }
370
268
  if (!isHealthy(opened.db)) {
371
269
  shutdownDb(opened.db);
372
270
  await quarantineStore(storePath);
373
271
  existed = false;
374
- opened = await openDbWithBusyRetryAsync(storePath);
272
+ opened = await openDbWithBusyRetryAsync(() => openDb(storePath));
375
273
  }
376
274
  const { db, stmts } = opened;
377
275
 
276
+ if (process.platform !== "win32") {
277
+ for (const candidate of [storePath, `${storePath}-wal`, `${storePath}-shm`]) {
278
+ try {
279
+ await chmod(candidate, 0o600);
280
+ } catch (error) {
281
+ if (errCode(error) !== "ENOENT") throw error;
282
+ }
283
+ }
284
+ }
285
+
378
286
  if (!existed) {
379
287
  try {
380
288
  await migrateLegacy(db);
@@ -422,7 +330,7 @@ export function shutdownHashStore(): void {
422
330
  }
423
331
 
424
332
  export function withStore(fn: () => void): void {
425
- if (!cachedDb) {
333
+ if (!cachedDb || !cachedDb.db.isOpen) {
426
334
  throw new Error(STORE_NOT_OPEN_MESSAGE);
427
335
  }
428
336
  withBusyRetry(() => {
@@ -503,15 +411,6 @@ async function migrateLegacy(db: RawDb): Promise<void> {
503
411
  }
504
412
  }
505
413
 
506
- function cacheSnapshot(path: string, checksum: string, lineCount: number, hashes: string[]): void {
507
- snapshotCache.delete(path);
508
- snapshotCache.set(path, { checksum, lineCount, hashes: hashes.slice() });
509
- if (snapshotCache.size > SNAPSHOT_CACHE_LIMIT) {
510
- const oldest = snapshotCache.keys().next().value;
511
- if (oldest !== undefined) snapshotCache.delete(oldest);
512
- }
513
- }
514
-
515
414
  export function getSnapshot(
516
415
  store: HashStore,
517
416
  path: string,
package/src/insert.ts CHANGED
@@ -1,18 +1,17 @@
1
1
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
3
2
  import { Type } from "typebox";
4
3
  import { constants } from "fs";
5
4
  import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
6
5
  import { commitEdit } from "./commit";
7
6
  import { readNormFile, type NormFile } from "./file-reader";
8
- import { resolveInCwd } from "./fs-write";
9
7
  import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
10
8
  import { stripAnchorRow } from "./hashline/resolve";
11
9
  import { loadP, loadGuide } from "./prompts";
12
10
  import { normReq } from "./payload-contract";
13
- import { makeRenderCall, renderEditResult, type RPreview, type RRState } from "./replace-render";
14
- import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
11
+ import { isRec, rejectUnknownFields, splitLines } from "./utils";
15
12
  import { clearBoundaryBypass } from "./boundary-bypass";
13
+ import type { RPreview, RRState } from "./replace-render";
14
+ import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
16
15
 
17
16
  const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
18
17
 
@@ -158,32 +157,17 @@ export function buildInsertToolDef(): InsertToolDef {
158
157
  description: loadP("../prompts/insert.md"),
159
158
  promptSnippet: loadP("../prompts/insert-snippet.md"),
160
159
  promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
161
- prepareArguments: makePrepareArguments(),
162
- executionMode: "sequential",
160
+ ...editToolBase,
163
161
  parameters: insertToolSchema,
164
- renderShell: "default",
165
- renderCall: makeRenderCall(insertPreview, { getInput: getInsertInput, toolName: "insert" }),
166
- renderResult(result, { isPartial, expanded }, theme, context) {
167
- return renderEditResult(
168
- result as {
169
- content?: Array<{ type: string; text?: string }>;
170
- details?: ReplaceDetails;
171
- },
172
- { isPartial, expanded },
173
- theme,
174
- context,
175
- );
176
- },
177
-
162
+ renderCall: editRenderCallWrapper(insertPreview, getInsertInput, "insert"),
163
+ renderResult: editRenderResultWrapper,
178
164
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
179
165
  const canonical = normReq(params);
180
166
  assertInsertReq(canonical);
181
167
  const req = canonical;
182
168
  const path = req.path;
183
169
  const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
184
- const { absolute: absolutePath, resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
185
- return withFileMutationQueue(mutationTargetPath, async () => {
186
- abortIf(signal);
170
+ return queuedEdit(path, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
187
171
  const preload = await readNormFile(path, ctx.cwd, {
188
172
  signal,
189
173
  accessMode: constants.R_OK | constants.W_OK,
@@ -0,0 +1,27 @@
1
+ export type LineEnding = "\r\n" | "\n" | "\r";
2
+
3
+ export function detectEnding(content: string): LineEnding {
4
+ const crIdx = content.indexOf("\r");
5
+ const lfIdx = content.indexOf("\n");
6
+ if (crIdx === -1 && lfIdx === -1) return "\n";
7
+ if (crIdx === -1) return "\n";
8
+ if (lfIdx === -1) return "\r";
9
+ if (crIdx < lfIdx) return content[crIdx + 1] === "\n" ? "\r\n" : "\r";
10
+ return "\n";
11
+ }
12
+
13
+ export function toLF(text: string): string {
14
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
15
+ }
16
+
17
+ export function restoreEndings(text: string, ending: LineEnding): string {
18
+ if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
19
+ if (ending === "\r") return text.replace(/\n/g, "\r");
20
+ return text;
21
+ }
22
+
23
+ export function stripBOM(content: string): { bom: string; text: string } {
24
+ return content.startsWith("\uFEFF")
25
+ ? { bom: "\uFEFF", text: content.slice(1) }
26
+ : { bom: "", text: content };
27
+ }
@@ -5,37 +5,15 @@ import {
5
5
  ANCHOR_LEN,
6
6
  HASH_SEP,
7
7
  } from "./hashline";
8
+ import {
9
+ detectEnding,
10
+ toLF,
11
+ restoreEndings,
12
+ stripBOM,
13
+ type LineEnding,
14
+ } from "./normalize";
8
15
 
9
- export type LineEnding = "\r\n" | "\n" | "\r";
10
-
11
- export function detectEnding(content: string): LineEnding {
12
- const crIdx = content.indexOf("\r");
13
- const lfIdx = content.indexOf("\n");
14
- if (crIdx === -1 && lfIdx === -1) return "\n";
15
- if (crIdx === -1) return "\n";
16
- if (lfIdx === -1) return "\r";
17
- if (crIdx < lfIdx) return content[crIdx + 1] === "\n" ? "\r\n" : "\r";
18
- return "\n";
19
- }
20
-
21
- export function toLF(text: string): string {
22
- return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
23
- }
24
-
25
- export function restoreEndings(
26
- text: string,
27
- ending: LineEnding,
28
- ): string {
29
- if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
30
- if (ending === "\r") return text.replace(/\n/g, "\r");
31
- return text;
32
- }
33
-
34
- export function stripBOM(content: string): { bom: string; text: string } {
35
- return content.startsWith("\uFEFF")
36
- ? { bom: "\uFEFF", text: content.slice(1) }
37
- : { bom: "", text: content };
38
- }
16
+ export { detectEnding, toLF, restoreEndings, stripBOM, type LineEnding };
39
17
 
40
18
  function fmtDiffLine(
41
19
  prefix: " " | "+" | "-",
@@ -1,11 +1,13 @@
1
- import { readFile } from "fs/promises";
1
+ import { constants } from "fs";
2
+ import { open } from "fs/promises";
2
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
4
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
5
  import { Type } from "typebox";
5
6
  import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
6
7
  import { recordServedDiff } from "./served";
7
- import { resolveInCwd, writeAtomic } from "./fs-write";
8
- import { toLF, stripBOM, genDiff, genPatch, restoreEndings, type LineEnding } from "./replace-diff";
8
+ import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
9
+ import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
10
+ import { genDiff, genPatch } from "./replace-diff";
9
11
  import { cntDiff, errCode, makePrepareArguments } from "./utils";
10
12
  import { loadP, loadGuide } from "./prompts";
11
13
  import { buildMetrics } from "./replace-response";
@@ -121,8 +123,17 @@ export function regUndo(pi: ExtensionAPI): void {
121
123
 
122
124
  return withFileMutationQueue(mutationTargetPath, async () => {
123
125
  let currentRaw: string | undefined;
126
+ let currentIdentity: FileIdentity | undefined;
124
127
  try {
125
- currentRaw = await readFile(mutationTargetPath, "utf-8");
128
+ const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
129
+ const handle = await open(mutationTargetPath, constants.O_RDONLY | noFollow);
130
+ try {
131
+ const { dev, ino } = await handle.stat();
132
+ currentIdentity = { dev, ino };
133
+ currentRaw = await handle.readFile("utf-8");
134
+ } finally {
135
+ await handle.close();
136
+ }
126
137
  } catch (error) {
127
138
  if (errCode(error) !== "ENOENT") throw error;
128
139
  }
@@ -146,6 +157,7 @@ export function regUndo(pi: ExtensionAPI): void {
146
157
  await writeAtomic(
147
158
  mutationTargetPath,
148
159
  undo.bom + restoreEndings(undo.content, undo.originalEnding),
160
+ currentIdentity,
149
161
  );
150
162
 
151
163
  const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
package/src/replace.ts CHANGED
@@ -2,7 +2,6 @@ import type {
2
2
  ExtensionAPI,
3
3
  ToolDefinition,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
5
  import { constants } from "fs";
7
6
  import {
8
7
  genDiff,
@@ -10,9 +9,9 @@ import {
10
9
  } from "./replace-diff";
11
10
  import { readNormFile, type NormFile } from "./file-reader";
12
11
  import { editToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
13
- import { isRec, abortIf, makePrepareArguments } from "./utils";
12
+ import { isRec } from "./utils";
14
13
  import { loadP, loadGuide } from "./prompts";
15
- import { resolveInCwd } from "./fs-write";
14
+ import { type FileIdentity } from "./fs-write";
16
15
  import { applyEdit,
17
16
  lineHashes,
18
17
  resEdit,
@@ -26,14 +25,13 @@ import { applyEdit,
26
25
  import { commitEdit } from "./commit";
27
26
  import type { RMetrics } from "./replace-response";
28
27
  import {
29
- makeRenderCall,
30
- renderEditResult,
31
28
  type RPreview,
32
29
  type RRState,
33
30
  } from "./replace-render";
34
31
  import { loadHashStore, findSnapshotPaths, findServedPaths, type HashStore } from "./hash-store";
35
32
  import { getServed, recordServedSafe } from "./served";
36
33
  import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
34
+ import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
37
35
 
38
36
  export { editToolSchema, type ReqParams, assertReq };
39
37
 
@@ -65,6 +63,7 @@ export interface PipelineResult {
65
63
  totalRemovedLines: number;
66
64
  hadBoundaryDedup: boolean;
67
65
  boundaryRemovedLines: number;
66
+ identity: FileIdentity;
68
67
  }
69
68
 
70
69
  async function resolveMissingPath(
@@ -170,7 +169,7 @@ export async function execPipeline(
170
169
  );
171
170
 
172
171
  const hashStore = options?.store ?? await loadHashStore();
173
- const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
172
+ const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath, identity } = await readNormFile(
174
173
  path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist, preloadedNorm: options?.preloadedNorm },
175
174
  );
176
175
 
@@ -227,6 +226,7 @@ export async function execPipeline(
227
226
  totalRemovedLines,
228
227
  hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
229
228
  boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
229
+ identity,
230
230
  };
231
231
  }
232
232
 
@@ -277,22 +277,9 @@ export function buildToolDef(): ToolDef {
277
277
  parameters,
278
278
  promptSnippet: E_SNIPPET,
279
279
  promptGuidelines: E_GUIDE,
280
- prepareArguments: makePrepareArguments(),
281
- executionMode: "sequential",
282
- renderShell: "default",
283
- renderCall: makeRenderCall(compPreview),
284
- renderResult(result, { isPartial, expanded }, theme, context) {
285
- return renderEditResult(
286
- result as {
287
- content?: Array<{ type: string; text?: string }>;
288
- details?: ReplaceDetails;
289
- },
290
- { isPartial, expanded },
291
- theme,
292
- context,
293
- );
294
- },
295
-
280
+ ...editToolBase,
281
+ renderCall: editRenderCallWrapper(compPreview),
282
+ renderResult: editRenderResultWrapper,
296
283
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
297
284
  const canonical = normReq(params);
298
285
  const resolution = isRec(canonical) ? await resolveMissingPath(canonical) : undefined;
@@ -300,14 +287,11 @@ export function buildToolDef(): ToolDef {
300
287
  canonical.path = resolution.path;
301
288
  }
302
289
  assertReq(canonical);
303
-
304
290
  const normalizedParams = canonical;
305
291
  const path = normalizedParams.path;
306
- const { absolute: absolutePath, resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
307
- const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
308
- const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
309
- return withFileMutationQueue(mutationTargetPath, async () => {
310
- abortIf(signal);
292
+ return queuedEdit(path, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
293
+ const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
294
+ const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
311
295
  const pipe = await execPipeline(
312
296
  normalizedParams,
313
297
  ctx.cwd,
package/src/validation.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { constants } from "fs";
2
2
  import { access as fsAccess } from "fs/promises";
3
3
  import type { LFile } from "./file-kind";
4
+ import type { FileIdentity } from "./fs-write";
4
5
  import { errCode } from "./utils";
5
6
 
6
7
  export async function valAccess(
@@ -26,7 +27,7 @@ export async function valAccess(
26
27
  }
27
28
  }
28
29
 
29
- export function valKind(file: LFile, path: string): asserts file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
30
+ export function valKind(file: LFile, path: string): asserts file is { kind: "text"; text: string; identity?: FileIdentity; hadUtf8DecodeErrors?: true } {
30
31
  if (file.kind === "directory") {
31
32
  throw new Error(`[E_NOT_TEXT] Path is a directory: ${path}. Use ls to inspect directories.`);
32
33
  }
@@ -42,4 +43,3 @@ export function valKind(file: LFile, path: string): asserts file is { kind: "tex
42
43
  );
43
44
  }
44
45
  }
45
-