pi-hashline-edit-pro 6.4.0 → 6.4.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
@@ -315,7 +315,7 @@ Multiple `replace`, `replace_match`, `insert`, `copy`, and `move` calls on the s
315
315
  - A call outside a batch commits before its result returns.
316
316
  - A cross-file `move` whose source file also has batched edits in the same message is not grouped: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
317
317
  - A batched cross-file `move` displays only the destination diff: the arrival is covered by the batch's undo, and the source removal is committed with the same batch but is not shown. Read the source file for fresh anchors; it keeps its own undo entry, and `details.patch` still contains the source patch.
318
- - A batch validates every call against the pre-batch state and commits once, during the batch's last applied call: earlier applied calls reply `In batch N (queued)`, and the batch's last applied call shows the combined diff, with one undo reverting the whole batch. A `copy` always duplicates the content its source anchors were served from: when the source file is also edited in the message, the copy reads that file's pre-batch state, even if the source batch commits before the copy runs. A batched cross-file `move` defers the source-file removal to the batch commit, so if the batch aborts, the source file is untouched.
318
+ - A batch validates every call against the pre-batch state and commits once, during the batch's last applied call: earlier applied calls reply `In batch N (queued)`, and the batch's last applied call shows the combined diff, with one undo reverting the whole batch. A `copy` always duplicates the content its source anchors were served from: when the source file is also edited in the message, the copy reads that file's pre-edit state, even if the source batch commits before the copy runs. A batched cross-file `move` defers the source-file removal to the batch commit, so if the batch aborts, the source file is untouched.
319
319
  - Before committing, the batch validates every call against the pre-batch state. A call whose own request is invalid (`[E_BAD_SHAPE]`, `[E_BAD_REF]`, `[E_STALE_ANCHOR]`, `[E_RANGE_STALE]`, `[E_SUBSTRING_NOT_FOUND]`, or a strict-input rejection) is left out: it fails on its own call without an `Aborts batch N.` suffix, and the remaining calls still commit as one batch with one undo. A failure of the file, the commit, or a cross-file member aborts the whole batch instead: nothing is written, the failing call's error ends with `Aborts batch N.`, and an earlier member's row renders the abort message instead of the queued placeholder.
320
320
  - A batch member accepts the same request shapes and auto-fixes as a standalone call.
321
321
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.4.0",
3
+ "version": "6.4.1",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
package/src/batch.ts CHANGED
@@ -4,6 +4,7 @@ import { toDisplayPath } from "./paths";
4
4
  import { readConfig, getDiffContextLines } from "./config";
5
5
  import { throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
6
6
  import { readNormFile, safeSnapId } from "./file-reader";
7
+ import { clearPreEditSnapshots, recordPreEditSnapshot } from "./pre-edit-snapshot";
7
8
  import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
8
9
  import {
9
10
  AnchorMismatchError,
@@ -27,7 +28,7 @@ import { saveUndo } from "./replace-undo";
27
28
  import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
28
29
  import { toEditVerb, withStructuredText, type EditStructured } from "./structured";
29
30
  import { serveRows, servedHashesFromDiff } from "./served";
30
- import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, splitLines } from "./utils";
31
+ import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, mapIoError, splitLines } from "./utils";
31
32
 
32
33
  export interface PlannedMember {
33
34
  batchKey: number;
@@ -231,7 +232,9 @@ export function abortedBatchMessageFor(toolCallId: string): string | undefined {
231
232
  }
232
233
 
233
234
  export function batchMemberFor(toolCallId: string): PlannedMember | undefined {
234
- return plan.get(toolCallId);
235
+ const member = plan.get(toolCallId);
236
+ if (!member || !batches.has(member.batchKey)) return undefined;
237
+ return member;
235
238
  }
236
239
 
237
240
  export function pendingBatchMemberFor(path: string): PlannedMember | undefined {
@@ -255,6 +258,7 @@ export function resetBatchStateForTests(): void {
255
258
  abortedMembers.clear();
256
259
  placeholderResults.clear();
257
260
  nextBatchKey = 1;
261
+ clearPreEditSnapshots();
258
262
  }
259
263
 
260
264
  function normalizeEditArgs(name: string, args: unknown): NormalizedEditArgs | undefined {
@@ -371,7 +375,9 @@ function enforceCap(): void {
371
375
  }
372
376
 
373
377
  export async function planAssistantMessage(message: unknown, cwd: string): Promise<void> {
374
- if (!isRec(message) || message.role !== "assistant" || !Array.isArray(message.content)) return;
378
+ if (!isRec(message) || message.role !== "assistant") return;
379
+ clearPreEditSnapshots();
380
+ if (!Array.isArray(message.content)) return;
375
381
  const calls: EditCall[] = [];
376
382
  for (const block of message.content) {
377
383
  if (!isRec(block) || block.type !== "toolCall") continue;
@@ -931,12 +937,24 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
931
937
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
932
938
  return combinedNoop(paths.displayPath, member, runtime, snapshotId);
933
939
  }
940
+
941
+ recordPreEditSnapshot({
942
+ absolutePath: runtime.target,
943
+ normalized: base.content,
944
+ bom: base.bom,
945
+ originalEnding: base.ending,
946
+ endingSeparators: base.separators,
947
+ fileHashes: base.hashes,
948
+ hadUtf8DecodeErrors: base.hadUtf8DecodeErrors,
949
+ identity: base.identity,
950
+ served: runtime.served ? new Map(runtime.served) : undefined,
951
+ });
934
952
  abortIf(signal);
935
953
  let currentRaw: string;
936
954
  try {
937
955
  currentRaw = await readFile(runtime.target, "utf-8");
938
956
  } catch (error) {
939
- if (errCode(error) !== "ENOENT") throw error;
957
+ if (errCode(error) !== "ENOENT") throw mapIoError(error, "read", runtime.target);
940
958
  discardBatchState(runtime);
941
959
  throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file was deleted after the batch started.`);
942
960
  }
@@ -962,7 +980,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
962
980
  try {
963
981
  sourceRaw = await readFile(source.mutationTargetPath, "utf-8");
964
982
  } catch (error) {
965
- if (errCode(error) !== "ENOENT") throw error;
983
+ if (errCode(error) !== "ENOENT") throw mapIoError(error, "read", source.displayPath);
966
984
  sourceRaw = undefined;
967
985
  }
968
986
  if (sourceRaw === undefined || toLF(stripBOM(sourceRaw).text) !== source.pipe.originalNormalized) {
@@ -1196,4 +1214,5 @@ export async function finalizeTurn(toolCallIds: string[]): Promise<void> {
1196
1214
  }
1197
1215
  batches.delete(key);
1198
1216
  }
1217
+ clearPreEditSnapshots();
1199
1218
  }
package/src/commit.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import type { PipelineResult } from "./replace";
3
- import { abortIf, assertByteLimit, errCode, splitLines } from "./utils";
3
+ import { abortIf, assertByteLimit, errCode, mapIoError, splitLines } from "./utils";
4
4
  import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
5
5
  import { saveUndo } from "./replace-undo";
6
6
  import { getDiffContextLines } from "./config";
7
7
  import { safeSnapId } from "./file-reader";
8
8
  import { writeAtomic } from "./fs-write";
9
- import { formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
9
+ import { formatAnchorReclaimNotice, servedForPath, takeReclaimedPaths } from "./anchor-registry";
10
10
  import { servedHashesFromDiff, serveRows } from "./served";
11
+ import { recordPreEditSnapshot } from "./pre-edit-snapshot";
11
12
  import { lineHashes } from "./hashline";
12
13
  import { spanForEdit } from "./replace";
13
14
  import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
@@ -69,6 +70,19 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
69
70
  );
70
71
  }
71
72
 
73
+ const served = servedForPath(mutationTargetPath);
74
+ recordPreEditSnapshot({
75
+ absolutePath: mutationTargetPath,
76
+ normalized: pipe.originalNormalized,
77
+ bom: pipe.bom,
78
+ originalEnding: pipe.originalEnding,
79
+ endingSeparators: pipe.originalSeparators,
80
+ fileHashes: pipe.originalHashes,
81
+ hadUtf8DecodeErrors: pipe.hadUtf8DecodeErrors,
82
+ identity: pipe.identity,
83
+ served: served ? new Map(served) : undefined,
84
+ });
85
+
72
86
  assertByteLimit(finalFileBytes, path);
73
87
 
74
88
  if (pipe.hadUtf8DecodeErrors) {
@@ -86,7 +100,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
86
100
  if (code === "ENOENT") currentRaw = undefined;
87
101
  else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${path}`);
88
102
  else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${path}`);
89
- else throw error;
103
+ else throw mapIoError(error, "read", path);
90
104
  }
91
105
  if (currentRaw === undefined) {
92
106
  throw new Error(`[E_OP_ABORTED] Edit aborted: the file was deleted after the edit started.`);
package/src/copy-move.ts CHANGED
@@ -5,6 +5,7 @@ import { constants } from "node:fs";
5
5
  import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type ReplaceDetails } from "./replace";
6
6
  import { commitEdit } from "./commit";
7
7
  import { readNormFile, safeSnapId, type NormFile } from "./file-reader";
8
+ import { preEditSnapshotFor } from "./pre-edit-snapshot";
8
9
  import {
9
10
  lineHashes,
10
11
  MAX_HASH_LINES,
@@ -236,11 +237,17 @@ async function prepareCrossTransfer(input: {
236
237
  const { kind, refs, sourcePath, destinationPath, cwd, signal, noPersist } = input;
237
238
  const common = { signal, maxLines: MAX_HASH_LINES };
238
239
  const sourceAccessMode = kind === "move" ? constants.R_OK | constants.W_OK : constants.R_OK;
239
- const sourcePreload = input.sourcePreload ?? (await readNormFile(sourcePath, cwd, noPersist ? { ...common, accessMode: constants.R_OK, noPersist: true, allocation: "shadow" } : { ...common, accessMode: sourceAccessMode }));
240
+ const candidate = kind === "copy" && input.sourcePreload === undefined ? preEditSnapshotFor(sourcePath) : undefined;
241
+ const snapshotPreload = candidate !== undefined
242
+ && candidate.fileHashes.includes(refs.sourceFrom.hash)
243
+ && candidate.fileHashes.includes(refs.sourceTo.hash)
244
+ ? candidate
245
+ : undefined;
246
+ const sourcePreload = input.sourcePreload ?? snapshotPreload ?? (await readNormFile(sourcePath, cwd, noPersist ? { ...common, accessMode: constants.R_OK, noPersist: true, allocation: "shadow" } : { ...common, accessMode: sourceAccessMode }));
240
247
  const destinationPreload = input.destinationPreload ?? (await readNormFile(destinationPath, cwd, noPersist ? { ...common, accessMode: constants.R_OK, noPersist: true, allocation: "shadow" } : { ...common, accessMode: constants.R_OK | constants.W_OK }));
241
248
  const sourceDisplay = toDisplayPath(cwd, sourcePreload.absolutePath, sourcePath);
242
249
  const destinationDisplay = toDisplayPath(cwd, destinationPreload.absolutePath, destinationPath);
243
- const sourceServed = input.sourceServed ?? servedForPath(sourcePreload.absolutePath);
250
+ const sourceServed = input.sourceServed ?? (snapshotPreload !== undefined ? snapshotPreload.served : servedForPath(sourcePreload.absolutePath));
244
251
  const destinationServed = input.destinationServed ?? servedForPath(destinationPreload.absolutePath);
245
252
  const sourceLines = splitLines(sourcePreload.normalized);
246
253
  const destinationLines = splitLines(destinationPreload.normalized);
@@ -667,7 +674,7 @@ async function executeBatchCrossCopy(
667
674
  const sourceMember = pendingBatchMemberFor(sourcePath);
668
675
  const sourcePreload = sourceMember
669
676
  ? preloadFromBase(await ensureBatchBase({ member: sourceMember, targetPath: sourcePath, mutationTargetPath: sourceMember.target, cwd, signal }))
670
- : await readNormFile(sourcePath, cwd, { signal, accessMode: constants.R_OK, maxLines: MAX_HASH_LINES });
677
+ : undefined;
671
678
  prepared = await prepareCrossTransfer({
672
679
  kind: "copy",
673
680
  refs,
package/src/fs-write.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  } from "node:fs/promises";
14
14
  import { dirname, join, parse, resolve, sep } from "node:path";
15
15
  import { toCwd } from "./paths";
16
- import { errCode, isModeUnsupported } from "./utils";
16
+ import { errCode, isModeUnsupported, mapIoError } from "./utils";
17
17
 
18
18
  export interface FileIdentity {
19
19
  dev: number;
@@ -138,7 +138,7 @@ export async function resolveInCwd(path: string, cwd: string): Promise<{ absolut
138
138
  const resolved = await resolveTarget(absolute);
139
139
  return { absolute, resolved };
140
140
  }
141
- export async function writeAtomic(
141
+ async function writeAtomicRaw(
142
142
  path: string,
143
143
  content: string,
144
144
  expectedIdentity?: FileIdentity,
@@ -223,3 +223,15 @@ export async function writeAtomic(
223
223
  throw error;
224
224
  }
225
225
  }
226
+ export async function writeAtomic(
227
+ path: string,
228
+ content: string,
229
+ expectedIdentity?: FileIdentity,
230
+ restoreMode?: number,
231
+ ): Promise<void> {
232
+ try {
233
+ await writeAtomicRaw(path, content, expectedIdentity, restoreMode);
234
+ } catch (error) {
235
+ throw mapIoError(error, "write", path);
236
+ }
237
+ }
@@ -35,9 +35,13 @@ export function splitSeparators(raw: string): LineEnding[] {
35
35
  export function joinSeparators(content: string, separators: readonly LineEnding[]): string {
36
36
  let out = "";
37
37
  let separatorIndex = 0;
38
- for (const char of content) {
38
+ for (let index = 0; index < content.length; index += 1) {
39
+ const char = content[index]!;
39
40
  if (char === "\n") {
40
- out += separators[separatorIndex] ?? "\n";
41
+ const separator = separators[separatorIndex] ?? "\n";
42
+ const emptyLineBefore = content[index - 1] === "\n";
43
+ const followsLoneCr = separator === "\n" && separators[separatorIndex - 1] === "\r" && emptyLineBefore;
44
+ out += followsLoneCr ? "\r\n" : separator;
41
45
  separatorIndex += 1;
42
46
  } else {
43
47
  out += char;
@@ -0,0 +1,19 @@
1
+ import type { NormFile } from "./file-reader";
2
+
3
+ export interface PreEditSnapshot extends NormFile {
4
+ served?: ReadonlyMap<string, string>;
5
+ }
6
+
7
+ const snapshots = new Map<string, PreEditSnapshot>();
8
+
9
+ export function recordPreEditSnapshot(snapshot: PreEditSnapshot): void {
10
+ snapshots.set(snapshot.absolutePath, snapshot);
11
+ }
12
+
13
+ export function preEditSnapshotFor(path: string): PreEditSnapshot | undefined {
14
+ return snapshots.get(path);
15
+ }
16
+
17
+ export function clearPreEditSnapshots(): void {
18
+ snapshots.clear();
19
+ }
@@ -163,6 +163,8 @@ function trimPlacedSpans(
163
163
  placed: PlacedSpan[],
164
164
  oldLines: string[],
165
165
  newLines: string[],
166
+ oldHashes: string[],
167
+ newHashes: string[],
166
168
  ): PlacedSpan[] {
167
169
  const out: PlacedSpan[] = [];
168
170
  for (const span of placed) {
@@ -170,11 +172,11 @@ function trimPlacedSpans(
170
172
  let oldEnd = span.oldEnd;
171
173
  let newStart = span.newStart;
172
174
  let newEnd = span.newEnd;
173
- while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart]) {
175
+ while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart] && oldHashes[oldStart] === newHashes[newStart]) {
174
176
  oldStart += 1;
175
177
  newStart += 1;
176
178
  }
177
- while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd]) {
179
+ while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd] && oldHashes[oldEnd] === newHashes[newEnd]) {
178
180
  oldEnd -= 1;
179
181
  newEnd -= 1;
180
182
  }
@@ -251,7 +253,7 @@ function genSpanDiff(
251
253
  if (oldHashes.length !== oldLines.length || newHashes.length !== newLines.length) return undefined;
252
254
  const placed = placeSpans(oldLines.length, newLines.length, spans);
253
255
  if (!placed) return undefined;
254
- const trimmed = trimPlacedSpans(placed, oldLines, newLines);
256
+ const trimmed = trimPlacedSpans(placed, oldLines, newLines, oldHashes, newHashes);
255
257
  const ordered = [...trimmed].sort((a, b) => a.oldStart - b.oldStart || a.newStart - b.newStart);
256
258
  let oldPos = 0;
257
259
  let newPos = 0;
@@ -12,7 +12,7 @@ import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
12
12
  import { joinSeparators } from "./line-endings";
13
13
  import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
14
14
  import { getDiffContextLines } from "./config";
15
- import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
15
+ import { cntDiff, errCode, makePrepareArguments, mapIoError, splitLines } from "./utils";
16
16
  import { loadP, loadGuidelines } from "./prompts";
17
17
  import { withUndoPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
18
18
  import { buildMetrics } from "./replace-response";
@@ -166,7 +166,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
166
166
  await handle.close();
167
167
  }
168
168
  } catch (error) {
169
- if (errCode(error) !== "ENOENT") throw error;
169
+ if (errCode(error) !== "ENOENT") throw mapIoError(error, "read", path);
170
170
  }
171
171
 
172
172
  if (
package/src/structured.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { HASH_CLASS, HASH_SEP } from "./hashline/hash";
3
+ import { ioErrorCode } from "./utils";
3
4
 
4
5
  export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
5
6
 
@@ -200,7 +201,7 @@ export function messageOf(error: unknown): string {
200
201
 
201
202
  export function structuredError(error: unknown): StructuredError {
202
203
  const message = messageOf(error);
203
- return { ok: false, kind: "error", error: { code: ERROR_CODE_RE.exec(message)?.[1] ?? "E_TOOL", message } };
204
+ return { ok: false, kind: "error", error: { code: ERROR_CODE_RE.exec(message)?.[1] ?? ioErrorCode(error) ?? "E_TOOL", message } };
204
205
  }
205
206
 
206
207
  export type StructuredFailureResult<TDetails> = {
package/src/utils.ts CHANGED
@@ -94,6 +94,35 @@ export function errCode(error: unknown): string | undefined {
94
94
  return undefined;
95
95
  }
96
96
 
97
+ const IO_ERROR_CODES: Record<string, string> = {
98
+ ENOENT: "E_NOT_FOUND",
99
+ EACCES: "E_ACCESS",
100
+ EPERM: "E_ACCESS",
101
+ EROFS: "E_ACCESS",
102
+ EISDIR: "E_ACCESS",
103
+ ENOTDIR: "E_ACCESS",
104
+ EEXIST: "E_ACCESS",
105
+ ELOOP: "E_ACCESS",
106
+ ENOSPC: "E_ACCESS",
107
+ EDQUOT: "E_ACCESS",
108
+ EMFILE: "E_ACCESS",
109
+ ENFILE: "E_ACCESS",
110
+ ENAMETOOLONG: "E_ACCESS",
111
+ };
112
+
113
+ export function ioErrorCode(error: unknown): string | undefined {
114
+ const code = errCode(error);
115
+ return code !== undefined ? IO_ERROR_CODES[code] : undefined;
116
+ }
117
+
118
+ export function mapIoError(error: unknown, action: string, path: string): Error {
119
+ if (error instanceof Error && /\[[EWH]_[A-Z0-9_]+\]/.test(error.message)) return error;
120
+ const mapped = ioErrorCode(error);
121
+ if (mapped === undefined) return error instanceof Error ? error : new Error(String(error));
122
+ const detail = error instanceof Error ? error.message : String(error);
123
+ return new Error(`[${mapped}] Cannot ${action} ${path}: ${detail}`);
124
+ }
125
+
97
126
  export function isModeUnsupported(error: unknown): boolean {
98
127
  const code = errCode(error);
99
128
  return code === "EPERM" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EINVAL" || code === "ENOSYS";