pi-hashline-edit-pro 4.2.1 → 4.2.2

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
@@ -93,11 +93,11 @@ Common copy-paste slips are fixed automatically and reported as warnings: a left
93
93
 
94
94
  Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown — for example beyond an auto-read preview's truncation cap — is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
95
95
 
96
- An edit that produces identical content reports `No changes made` and leaves the anchors alone. When a noop happened because the boundary anti-duplication cut a line from the replacement, sending the same replacement once more runs with that dedup turned off for the single call and applies the lines literally; the result carries a `[W_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to the payload; copied prefixes, diff markers, and stray whitespace are normalized before matching. Any applied edit or successful `write` clears it.
96
+ An edit that produces identical content reports `No changes made` and leaves the anchors alone. When a noop happened because the boundary anti-duplication cut a line from the replacement, sending the same replacement once more runs with that dedup turned off for the single call and applies the lines literally; the result carries a `[W_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to the payload; copied prefixes, diff markers, and stray whitespace are normalized before matching. Any applied edit or successful `write` clears it. A pending bypass overrides `strict` mode for its one resend; an aborted batch preserves a consumed bypass for retry.
97
97
 
98
98
  After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as a marker that keeps the row's anchor, and only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch`.
99
99
 
100
- Multiple `replace` and `insert` calls on the same file in one message are grouped per file into one batch that validates every call against the pre-batch state and then applies them together on the batch's last call: earlier calls reply `In batch` (`In batch N` when several files batch) and the batch's last call shows the combined diff, with one undo reverting the whole batch. Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch with nothing written. Verify each batch diff before the next turn's edits on that file.
100
+ Multiple `replace` and `insert` calls on the same file in one message are grouped per file into one batch that validates every call against the pre-batch state and then applies them together on the batch's last call: earlier calls reply `In batch` (`In batch N` when several files batch) and the batch's last call shows the combined diff, with one undo reverting the whole batch. Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch. Calls with stale anchors join their file's batch through a `requirePath` path hint or a valid co-anchor and abort it instead of applying partially; a same-turn sibling whose anchors resolve nowhere still aborts the batch when no other file is being edited. An error that aborts a batch ends with `Aborts batch N.`, while the abort itself reads `[E_OP_ABORTED] Batch N aborted.` Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
101
101
 
102
102
  ### insert
103
103
 
@@ -236,8 +236,8 @@ Codes starting with `E_` are errors (the operation failed); codes starting with
236
236
  | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
237
237
  | `[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). |
238
238
  | `[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. |
239
- | `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused with nothing written. Retry with disjoint ranges. |
240
- | `[E_BATCH_ABORTED]` | A same-turn edit batch aborted (a member failed, or the file changed mid-turn); nothing was written. The first failure is quoted; fix it and retry the batch. |
239
+ | `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. Retry with disjoint ranges. |
240
+ | `[E_OP_ABORTED]` | An edit aborted (a same-turn batch member failed, or the file changed or was deleted after the edit started). Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. |
241
241
  | `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
242
242
 
243
243
  ## Troubleshooting
package/index.ts CHANGED
@@ -11,7 +11,7 @@ import type { ReplaceDetails } from "./src/replace";
11
11
  import { extractWarnings } from "./src/replace-render";
12
12
  import { MAX_HASH_LINES } from "./src/hashline";
13
13
  import {
14
- readConfig,
14
+ readConfigWithStatus,
15
15
  toggleAutoRead,
16
16
  toggleAnchorGrep,
17
17
  toggleRequirePath,
@@ -75,7 +75,8 @@ export default function (pi: ExtensionAPI): void {
75
75
  const sessionFile = sessionManager?.getSessionFile?.();
76
76
  await initRegistry(sessionFile);
77
77
  await gcRegistrySidecars();
78
- const config = await readConfig();
78
+ const { config, corrupted } = await readConfigWithStatus();
79
+ if (corrupted && (ctx as { hasUI?: boolean }).hasUI) ctx.ui.notify("Hashline config was corrupt and was reset to defaults", "warning");
79
80
  autoRead = config.autoRead;
80
81
  await refreshEditTools();
81
82
  pi.setActiveTools(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.2.1",
3
+ "version": "4.2.2",
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",
@@ -1,4 +1,4 @@
1
- import { chmod, mkdir, readFile, readdir, rm, stat } from "fs/promises";
1
+ import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises";
2
2
  import { appendFileSync, chmodSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { createHash } from "crypto";
@@ -16,6 +16,7 @@ export type RegistryEvent =
16
16
  | { kind: "session"; sessionFile: string }
17
17
  | { kind: "allocate"; path: string; rows: [string, string][] }
18
18
  | { kind: "free"; path: string; anchors?: string[] }
19
+ | { kind: "minted"; anchors: string[] }
19
20
  | { kind: "clear" };
20
21
 
21
22
  export interface OwnedAnchor {
@@ -31,7 +32,9 @@ interface SessionState {
31
32
  }
32
33
 
33
34
  const SIDECAR_SUFFIX = ".registry.jsonl";
34
-
35
+ const SIDECAR_COMPACT_LINES = 5000;
36
+ const SIDECAR_COMPACT_BYTES = 1024 * 1024;
37
+ const SIDECAR_COMPACT_CHUNK = 5000;
35
38
  let currentKey: string | undefined;
36
39
  let currentSidecar: string | undefined;
37
40
  const registries = new Map<string, SessionState>();
@@ -62,6 +65,8 @@ export function foldRegistryEvents(events: RegistryEvent[]): SessionState {
62
65
  for (const event of events) {
63
66
  if (event.kind === "clear") {
64
67
  state.owned.clear();
68
+ } else if (event.kind === "minted") {
69
+ for (const anchor of event.anchors) state.everMinted.add(anchor);
65
70
  } else if (event.kind === "allocate") {
66
71
  for (const [anchor, checksum] of event.rows) {
67
72
  state.owned.set(anchor, { path: event.path, checksum });
@@ -90,7 +95,7 @@ export function parseRegistryLog(raw: string): RegistryEvent[] {
90
95
  if (!line.trim()) continue;
91
96
  try {
92
97
  const parsed = JSON.parse(line) as RegistryEvent;
93
- if (parsed && (parsed.kind === "allocate" || parsed.kind === "free" || parsed.kind === "clear" || parsed.kind === "session")) {
98
+ if (parsed && (parsed.kind === "allocate" || parsed.kind === "free" || parsed.kind === "clear" || parsed.kind === "session" || parsed.kind === "minted")) {
94
99
  events.push(parsed);
95
100
  }
96
101
  } catch {
@@ -107,6 +112,46 @@ function sidecarPath(key: string): string {
107
112
  function sidecarKeyFor(sessionFile: string): string {
108
113
  return createHash("sha256").update(sessionFile).digest("hex").slice(0, 24);
109
114
  }
115
+ export function shouldCompactSidecar(raw: string): boolean {
116
+ if (raw.length >= SIDECAR_COMPACT_BYTES) return true;
117
+ let lines = 0;
118
+ for (let i = 0; i < raw.length; i++) if (raw.charCodeAt(i) === 10) lines += 1;
119
+ return lines >= SIDECAR_COMPACT_LINES;
120
+ }
121
+ export function buildCompactedLog(sessionFile: string, state: SessionState): string {
122
+ const byPath = new Map<string, Array<[string, string]>>();
123
+ for (const [anchor, entry] of state.owned) {
124
+ const rows = byPath.get(entry.path) ?? [];
125
+ rows.push([anchor, entry.checksum]);
126
+ byPath.set(entry.path, rows);
127
+ }
128
+ const out: string[] = [JSON.stringify({ kind: "session", sessionFile })];
129
+ for (const [path, rows] of byPath) {
130
+ for (let i = 0; i < rows.length; i += SIDECAR_COMPACT_CHUNK) {
131
+ out.push(JSON.stringify({ kind: "allocate", path, rows: rows.slice(i, i + SIDECAR_COMPACT_CHUNK) }));
132
+ }
133
+ }
134
+ const freedHistory = [...state.everMinted].filter((anchor) => !state.owned.has(anchor));
135
+ for (let i = 0; i < freedHistory.length; i += SIDECAR_COMPACT_CHUNK) {
136
+ out.push(JSON.stringify({ kind: "minted", anchors: freedHistory.slice(i, i + SIDECAR_COMPACT_CHUNK) }));
137
+ }
138
+ return out.join("\n") + "\n";
139
+ }
140
+ async function compactSidecarIfNeeded(sidecar: string, raw: string, sessionFile: string, state: SessionState): Promise<void> {
141
+ if (!shouldCompactSidecar(raw)) return;
142
+ const tmp = `${sidecar}.compact-${Date.now()}-${Math.random().toString(36).slice(2)}`;
143
+ try {
144
+ const compacted = buildCompactedLog(sessionFile, state);
145
+ await writeFile(tmp, compacted, { mode: 0o600 });
146
+ if (process.platform !== "win32") {
147
+ try { await chmod(tmp, 0o600); } catch { }
148
+ }
149
+ await rename(tmp, sidecar);
150
+ } catch (error) {
151
+ console.error("Failed to compact anchor registry sidecar:", error);
152
+ try { await rm(tmp, { force: true }); } catch { }
153
+ }
154
+ }
110
155
 
111
156
  export async function initRegistry(sessionFile: string | undefined): Promise<void> {
112
157
  if (!sessionFile) {
@@ -119,9 +164,10 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
119
164
  currentKey = key;
120
165
  currentSidecar = sidecarPath(key);
121
166
  let events: RegistryEvent[] = [];
167
+ let rawLog = "";
122
168
  try {
123
- const raw = await readFile(currentSidecar, "utf-8");
124
- events = parseRegistryLog(raw);
169
+ rawLog = await readFile(currentSidecar, "utf-8");
170
+ events = parseRegistryLog(rawLog);
125
171
  } catch (error) {
126
172
  if (errCode(error) !== "ENOENT") {
127
173
  console.error("Failed to read anchor registry sidecar:", error);
@@ -130,6 +176,9 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
130
176
  const folded = foldRegistryEvents(events);
131
177
  seedServedFromOwned(folded);
132
178
  registries.set(key, folded);
179
+ if (rawLog.length > 0) {
180
+ await compactSidecarIfNeeded(currentSidecar, rawLog, sessionFile, folded);
181
+ }
133
182
  try {
134
183
  await mkdir(sessionClaimsDir(), { recursive: true, mode: 0o700 });
135
184
  if (process.platform !== "win32") {
@@ -595,6 +644,16 @@ export async function gcRegistrySidecars(): Promise<void> {
595
644
  return;
596
645
  }
597
646
  for (const name of names) {
647
+ if (name.includes(`${SIDECAR_SUFFIX}.compact-`)) {
648
+ const tmpPath = join(sessionClaimsDir(), name);
649
+ try {
650
+ const tmpStat = await stat(tmpPath);
651
+ if (Date.now() - tmpStat.mtimeMs > 60 * 60 * 1000) await rm(tmpPath, { force: true });
652
+ } catch (error) {
653
+ if (errCode(error) !== "ENOENT") console.error("Failed to inspect registry sidecar:", error);
654
+ }
655
+ continue;
656
+ }
598
657
  if (!name.endsWith(SIDECAR_SUFFIX)) continue;
599
658
  const sidecar = join(sessionClaimsDir(), name);
600
659
  try {
package/src/batch.ts CHANGED
@@ -2,7 +2,7 @@ import { readFile } from "fs/promises";
2
2
  import { constants } from "fs";
3
3
  import { relative } from "path";
4
4
  import { readConfig, getDiffContextLines } from "./config";
5
- import { throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
5
+ import { resolveEditTarget, throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
6
6
  import { readNormFile, safeSnapId } from "./file-reader";
7
7
  import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
8
8
  import {
@@ -50,6 +50,7 @@ interface BatchBase {
50
50
  hadUtf8DecodeErrors: boolean;
51
51
  absolutePath: string;
52
52
  snapshotId?: string;
53
+ baseLines: string[];
53
54
  }
54
55
 
55
56
  export interface BatchPiece {
@@ -65,6 +66,7 @@ export interface BatchPiece {
65
66
  noop: boolean;
66
67
  noopPayload?: string;
67
68
  foldedLines: number;
69
+ bypassConsumed?: boolean;
68
70
  }
69
71
 
70
72
  export interface BatchMemberInput {
@@ -80,6 +82,7 @@ export interface BatchMemberInput {
80
82
  strictBoundaryDedup: boolean;
81
83
  noopPayload?: string;
82
84
  foldedLines?: number;
85
+ bypassConsumed?: boolean;
83
86
  }
84
87
 
85
88
  interface BatchState {
@@ -97,6 +100,7 @@ interface BatchState {
97
100
  failures: number;
98
101
  failed: boolean;
99
102
  firstError?: unknown;
103
+ poisonedBy?: string;
100
104
  warnings: string[];
101
105
  }
102
106
 
@@ -144,13 +148,39 @@ function normalizeEditArgs(args: unknown): NormalizedEditArgs | undefined {
144
148
  }
145
149
  return undefined;
146
150
  }
147
-
148
151
  function anchorTargetFor(args: unknown): string | undefined {
149
152
  const normalized = normalizeEditArgs(args);
150
153
  if (!normalized) return undefined;
151
154
  if (normalized.kind === "replace") return tryResolveEditTarget(normalized.removeFrom, normalized.removeTo);
152
155
  return tryResolveEditTarget(normalized.anchor);
153
156
  }
157
+ function unresolvedErrorFor(call: EditCall): Error {
158
+ const normalized = normalizeEditArgs(call.args);
159
+ if (!normalized) return new Error(`sibling invalid`);
160
+ const refs = normalized.kind === "replace" ? [normalized.removeFrom, normalized.removeTo].filter((ref): ref is string => typeof ref === "string").join("→") : normalized.anchor;
161
+ try {
162
+ if (normalized.kind === "replace") resolveEditTarget(normalized.removeFrom, normalized.removeTo);
163
+ else resolveEditTarget(normalized.anchor);
164
+ } catch {
165
+ return new Error(`sibling stale (${refs})`);
166
+ }
167
+ return new Error(`sibling stale (${refs})`);
168
+ }
169
+ async function inferredTargetFor(args: unknown, cwd: string, requirePath: boolean): Promise<string | undefined> {
170
+ const normalized = normalizeEditArgs(args);
171
+ if (!normalized) return undefined;
172
+ if (requirePath && normalized.path) {
173
+ try {
174
+ return (await resolveInCwd(normalized.path, cwd)).resolved;
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+ if (normalized.kind === "replace") {
180
+ return tryResolveEditTarget(normalized.removeFrom) ?? (normalized.removeTo ? tryResolveEditTarget(normalized.removeTo) : undefined);
181
+ }
182
+ return undefined;
183
+ }
154
184
 
155
185
  async function verifyPaths(
156
186
  group: Array<{ id: string; target: string; kind: BatchKind; args: unknown }>,
@@ -191,10 +221,12 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
191
221
  calls.push({ id: block.id, name: block.name, args: block.arguments });
192
222
  }
193
223
  if (calls.length < 2) return;
224
+ const earlyConfig = await readConfig();
225
+ const requirePath = earlyConfig.requirePath === true;
194
226
  interface ResolvedCall { id: string; target: string; kind: BatchKind; args: unknown }
195
227
  const resolved: ResolvedCall[] = [];
196
228
  for (const call of calls) {
197
- const target = anchorTargetFor(call.args);
229
+ const target = anchorTargetFor(call.args) ?? await inferredTargetFor(call.args, cwd, requirePath);
198
230
  if (target) resolved.push({ id: call.id, target, kind: call.name as BatchKind, args: call.args });
199
231
  }
200
232
  const groups = new Map<string, ResolvedCall[]>();
@@ -204,10 +236,8 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
204
236
  groups.set(item.target, group);
205
237
  }
206
238
  const multi = [...groups.values()].filter((group) => group.length >= 2);
207
- if (multi.length === 0) return;
208
- const config = await readConfig();
209
239
  const finalGroups: ResolvedCall[][] = [];
210
- if (config.requirePath === true) {
240
+ if (requirePath) {
211
241
  for (const group of multi) {
212
242
  const verified = await verifyPaths(group, cwd);
213
243
  if (verified.length >= 2) finalGroups.push(verified);
@@ -215,11 +245,22 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
215
245
  } else {
216
246
  finalGroups.push(...multi);
217
247
  }
218
- if (finalGroups.length === 0) return;
248
+ const resolvedIds = new Set(resolved.map((item) => item.id));
249
+ const unplanned = calls.filter((call) => !resolvedIds.has(call.id));
250
+ let poison: { target: string; error: unknown; callId: string } | undefined;
251
+ if (unplanned.length > 0 && groups.size === 1) {
252
+ const sole = [...groups.values()][0]!;
253
+ const poisonTarget = sole[0]!.target;
254
+ const poisonCall = unplanned[0]!;
255
+ poison = { target: poisonTarget, error: unresolvedErrorFor(poisonCall), callId: poisonCall.id };
256
+ if (!finalGroups.some((group) => group[0]!.target === poisonTarget)) finalGroups.push(sole);
257
+ }
219
258
  let display = 0;
220
259
  for (const group of finalGroups) {
221
260
  display += 1;
222
261
  const key = nextBatchKey++;
262
+ const matchingPoison = poison !== undefined && group[0]!.target === poison.target ? poison : undefined;
263
+ const poisoned = matchingPoison !== undefined;
223
264
  batches.set(key, {
224
265
  display,
225
266
  target: group[0]!.target,
@@ -229,8 +270,9 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
229
270
  pieces: [],
230
271
  applied: 0,
231
272
  noops: 0,
232
- failures: 0,
233
- failed: false,
273
+ failures: poisoned ? 1 : 0,
274
+ failed: poisoned,
275
+ ...(matchingPoison ? { firstError: matchingPoison.error, poisonedBy: matchingPoison.callId } : {}),
234
276
  warnings: [],
235
277
  });
236
278
  group.forEach((item, index) => {
@@ -310,9 +352,21 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
310
352
  };
311
353
  }
312
354
 
355
+ export function withAbortSuffix(message: string, display: number): string {
356
+ const suffix = `Aborts batch ${display}.`;
357
+ if (message.includes(suffix)) return message;
358
+ return message.endsWith(".") ? `${message} ${suffix}` : `${message}. ${suffix}`;
359
+ }
360
+ export function suffixPoisonCause(toolCallId: string, error: unknown): void {
361
+ if (!(error instanceof Error)) return;
362
+ for (const runtime of batches.values()) {
363
+ if (runtime.poisonedBy === toolCallId) error.message = withAbortSuffix(error.message, runtime.display);
364
+ }
365
+ }
313
366
  export function noteBatchFailure(member: PlannedMember, error: unknown): void {
314
367
  const runtime = batches.get(member.batchKey);
315
368
  if (!runtime) return;
369
+ if (error instanceof Error && !error.message.startsWith("[E_OP_ABORTED]")) error.message = withAbortSuffix(error.message, member.display);
316
370
  runtime.failures += 1;
317
371
  if (!runtime.failed) {
318
372
  runtime.failed = true;
@@ -320,9 +374,15 @@ export function noteBatchFailure(member: PlannedMember, error: unknown): void {
320
374
  }
321
375
  }
322
376
 
323
- function batchAbortedError(runtime: BatchState): Error {
324
- const first = runtime.firstError instanceof Error ? runtime.firstError.message : String(runtime.firstError);
325
- return new Error(`[E_BATCH_ABORTED] Batch ${runtime.display} aborted; nothing was written. First failure: ${first}`);
377
+ function batchAbortedError(runtime: BatchState, input?: BatchMemberInput): Error {
378
+ restoreBatchBypasses(runtime, input);
379
+ return new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted.`);
380
+ }
381
+ function restoreBatchBypasses(runtime: BatchState, input?: BatchMemberInput): void {
382
+ for (const piece of runtime.pieces) {
383
+ if (piece.bypassConsumed && piece.noopPayload) markBoundaryNoop(runtime.target, piece.noopPayload);
384
+ }
385
+ if (input?.bypassConsumed && input.noopPayload) markBoundaryNoop(input.mutationTargetPath, input.noopPayload);
326
386
  }
327
387
 
328
388
  export async function ensureBatchBase(input: {
@@ -351,6 +411,7 @@ export async function ensureBatchBase(input: {
351
411
  hadUtf8DecodeErrors: file.hadUtf8DecodeErrors,
352
412
  absolutePath: file.absolutePath,
353
413
  ...(snapshotId !== undefined ? { snapshotId } : {}),
414
+ baseLines: splitLines(file.normalized),
354
415
  };
355
416
  runtime.base = base;
356
417
  runtime.served = servedForPath(file.absolutePath);
@@ -365,7 +426,7 @@ export async function ensureBatchBase(input: {
365
426
  export async function executeBatchMember(input: BatchMemberInput): Promise<TResult> {
366
427
  const runtime = batches.get(input.member.batchKey);
367
428
  if (!runtime) throw new Error(`[E_STALE_ANCHOR] Batch ${input.member.display} is no longer tracked. Call read for fresh anchors.`);
368
- if (runtime.failed) throw batchAbortedError(runtime);
429
+ if (runtime.failed) throw batchAbortedError(runtime, input);
369
430
  let base: BatchBase;
370
431
  try {
371
432
  base = await ensureBatchBase({
@@ -377,11 +438,13 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
377
438
  });
378
439
  } catch (error) {
379
440
  noteBatchFailure(input.member, error);
441
+ restoreBatchBypasses(runtime, input);
380
442
  throw error;
381
443
  }
382
444
  if (input.mutationTargetPath !== input.member.target) {
383
445
  const error = new Error(`[E_STALE_ANCHOR] "${input.hedit.hash_bounds[0].hash}" is no longer owned by ${input.member.target}. Call read for fresh anchors.`);
384
446
  noteBatchFailure(input.member, error);
447
+ restoreBatchBypasses(runtime, input);
385
448
  throw error;
386
449
  }
387
450
  const displayPath = runtime.paths?.displayPath ?? input.targetPath;
@@ -393,6 +456,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
393
456
  skipBoundaryDedup: input.skipBoundaryDedup,
394
457
  strictBoundaryDedup: input.strictBoundaryDedup,
395
458
  signal: input.signal,
459
+ baseFileLines: base.baseLines,
396
460
  });
397
461
  } catch (error) {
398
462
  if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
@@ -400,15 +464,17 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
400
464
  else if (error instanceof Error && error.message.startsWith("[E_BOUNDARY_STRICT]")) {
401
465
  const indexed = new Error(`edit #${input.member.order} strict boundary-dedup rejection: ${error.message}`);
402
466
  noteBatchFailure(input.member, indexed);
467
+ restoreBatchBypasses(runtime, input);
403
468
  throw indexed;
404
469
  }
405
470
  noteBatchFailure(input.member, error);
471
+ restoreBatchBypasses(runtime, input);
406
472
  throw error;
407
473
  }
408
474
  const start = planned.resolved.hash_bounds[0].line;
409
475
  const end = planned.resolved.hash_bounds[1].line;
410
476
  const newLines = planned.resolved.content_lines;
411
- const baseLines = splitLines(base.content);
477
+ const baseLines = base.baseLines;
412
478
  const originalSlice = baseLines.slice(start - 1, end);
413
479
  const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
414
480
  const autoFixes = planned.autoFixes?.length ?? 0;
@@ -425,6 +491,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
425
491
  noop,
426
492
  ...(input.noopPayload !== undefined ? { noopPayload: input.noopPayload } : {}),
427
493
  foldedLines: input.foldedLines ?? 0,
494
+ ...(input.bypassConsumed ? { bypassConsumed: true as const } : {}),
428
495
  };
429
496
  runtime.pieces.push(piece);
430
497
  if (input.kind === "replace") runtime.replaceCount += 1;
@@ -476,20 +543,27 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
476
543
  const prev = ordered[i - 1]!;
477
544
  const current = ordered[i]!;
478
545
  if (current.start <= prev.end) {
546
+ restoreBatchBypasses(runtime);
479
547
  throw new Error(`[E_BATCH_OVERLAP] Batch ${runtime.display} has overlapping ranges: ${formatBatchPiece(prev)} overlaps ${formatBatchPiece(current)}`);
480
548
  }
481
549
  }
550
+ const composed = composeBatchLines(base.content, appliedPieces);
482
551
  const warnings = [...runtime.warnings];
483
552
  if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
484
553
  const dedupTotal = runtime.pieces.reduce((sum, piece) => sum + piece.autoFixes, 0);
485
554
  if (dedupTotal > 0) warnings.push(boundaryDedupWarning(dedupTotal));
486
- await throwIfStrictInput(dedupeWarnings(warnings));
487
- const composed = composeBatchLines(base.content, appliedPieces);
488
- assertNotEmpty(base.content, composed);
489
- assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
490
- const finalBytes = base.bom + restoreEndings(composed, base.ending);
491
- if (Buffer.byteLength(finalBytes, "utf-8") > MAX_BYTES) {
492
- throw new Error(`[E_FILE_TOO_LARGE] File is too large: ${paths.displayPath} (exceeds the ${MAX_BYTES / (1024 * 1024)}MB size limit). For very large files, use write.`);
555
+ try {
556
+ await throwIfStrictInput(dedupeWarnings(warnings));
557
+ assertNotEmpty(base.content, composed);
558
+ assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
559
+ const finalBytes = base.bom + restoreEndings(composed, base.ending);
560
+ if (Buffer.byteLength(finalBytes, "utf-8") > MAX_BYTES) {
561
+ throw new Error(`[E_FILE_TOO_LARGE] File is too large: ${paths.displayPath} (exceeds the ${MAX_BYTES / (1024 * 1024)}MB size limit). For very large files, use write.`);
562
+ }
563
+ } catch (error) {
564
+ restoreBatchBypasses(runtime);
565
+ if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
566
+ throw error;
493
567
  }
494
568
  if (composed === base.content) {
495
569
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
@@ -501,10 +575,24 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
501
575
  currentRaw = await readFile(runtime.target, "utf-8");
502
576
  } catch (error) {
503
577
  if (errCode(error) !== "ENOENT") throw error;
504
- throw new Error(`[E_BATCH_ABORTED] Batch ${runtime.display} aborted: the file was deleted after the batch started; nothing was written.`);
578
+ restoreBatchBypasses(runtime);
579
+ throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file was deleted after the batch started.`);
505
580
  }
506
581
  if (toLF(stripBOM(currentRaw).text) !== base.content) {
507
- throw new Error(`[E_BATCH_ABORTED] Batch ${runtime.display} aborted: the file changed after the batch started; nothing was written. Call read for fresh anchors and retry.`);
582
+ restoreBatchBypasses(runtime);
583
+ throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file changed after the batch started. Call read for fresh anchors and retry.`);
584
+ }
585
+ const preflightSpans = appliedPieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
586
+ try {
587
+ await lineHashes(composed, runtime.target, {
588
+ content: base.content,
589
+ hashes: base.hashes,
590
+ spans: preflightSpans,
591
+ }, undefined, false, true);
592
+ } catch (error) {
593
+ restoreBatchBypasses(runtime);
594
+ if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
595
+ throw error;
508
596
  }
509
597
  const undo = await saveUndo(runtime.target, {
510
598
  content: base.content,
@@ -514,23 +602,32 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
514
602
  resultContent: composed,
515
603
  });
516
604
  if (!undo.persisted) {
517
- throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history; the edit was not applied and ${paths.displayPath} is unchanged.`);
605
+ restoreBatchBypasses(runtime);
606
+ throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${paths.displayPath}. Aborts batch ${runtime.display}.`);
518
607
  }
519
608
  try {
520
609
  abortIf(signal);
521
610
  await writeAtomic(paths.absolutePath, base.bom + restoreEndings(composed, base.ending), base.identity);
522
611
  } catch (error) {
523
612
  await undo.restore();
613
+ restoreBatchBypasses(runtime);
614
+ if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
524
615
  throw error;
525
616
  }
526
617
  clearBoundaryBypass(runtime.target);
527
618
  const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
528
619
  const spans = appliedPieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
529
- const resultHashes = await lineHashes(composed, runtime.target, {
530
- content: base.content,
531
- hashes: base.hashes,
532
- spans,
533
- });
620
+ let resultHashes: string[];
621
+ try {
622
+ resultHashes = await lineHashes(composed, runtime.target, {
623
+ content: base.content,
624
+ hashes: base.hashes,
625
+ spans,
626
+ });
627
+ } catch (error) {
628
+ const detail = error instanceof Error ? error.message : String(error);
629
+ throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
630
+ }
534
631
  const range = changedRange(base.content, composed);
535
632
  let added = 0;
536
633
  let removed = 0;
package/src/commit.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { readFile } from "fs/promises";
1
2
  import type { PipelineResult } from "./replace";
2
- import { abortIf } from "./utils";
3
+ import { abortIf, errCode, splitLines } from "./utils";
3
4
  import { DEDUP_ANCHOR } from "./constants";
4
5
  import { HASH_SEP } from "./hashline";
5
6
  import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
@@ -10,8 +11,7 @@ import { writeAtomic } from "./fs-write";
10
11
  import { servedHashesFromDiff, buildServedMap } from "./served";
11
12
  import { lineHashes } from "./hashline";
12
13
  import { hashSpan } from "./replace";
13
- import { restoreEndings } from "./normalize";
14
- import { splitLines } from "./utils";
14
+ import { restoreEndings, stripBOM, toLF } from "./normalize";
15
15
  import { markServed as markServedScoped } from "./anchor-registry";
16
16
  export interface CommitMeta {
17
17
  editAnchors?: [string, string];
@@ -71,6 +71,22 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
71
71
  }
72
72
 
73
73
  abortIf(signal);
74
+ let currentRaw: string | undefined;
75
+ try {
76
+ currentRaw = await readFile(mutationTargetPath, "utf-8");
77
+ } catch (error) {
78
+ const code = errCode(error);
79
+ if (code === "ENOENT") currentRaw = undefined;
80
+ else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${path}`);
81
+ else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${path}`);
82
+ else throw error;
83
+ }
84
+ if (currentRaw === undefined) {
85
+ throw new Error(`[E_OP_ABORTED] Edit aborted: the file was deleted after the edit started.`);
86
+ }
87
+ if (toLF(stripBOM(currentRaw).text) !== pipe.originalNormalized) {
88
+ throw new Error(`[E_OP_ABORTED] Edit aborted: the file changed after the edit started. Call read for fresh anchors and retry.`);
89
+ }
74
90
  const undo = await saveUndo(mutationTargetPath, {
75
91
  content: pipe.originalNormalized,
76
92
  bom: pipe.bom,
@@ -80,7 +96,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
80
96
  });
81
97
  if (!undo.persisted) {
82
98
  throw new Error(
83
- `[E_UNDO_UNAVAILABLE] Could not persist undo history; the edit was not applied and ${path} is unchanged.`
99
+ `[E_UNDO_UNAVAILABLE] Could not persist undo history for ${path}.`
84
100
  );
85
101
  }
86
102
  try {
@@ -109,13 +125,19 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
109
125
  const span = meta.editAnchors ? hashSpan(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1]) : undefined;
110
126
  const resultCount = splitLines(pipe.result).length;
111
127
  const replacementCount = span ? resultCount - (pipe.originalHashes.length - (span[1] - span[0] + 1)) : 0;
112
- const resultHashes = pipe.result === pipe.originalNormalized
113
- ? pipe.originalHashes
114
- : await lineHashes(pipe.result, mutationTargetPath, {
128
+ let resultHashes: string[];
129
+ try {
130
+ resultHashes = pipe.result === pipe.originalNormalized
131
+ ? pipe.originalHashes
132
+ : await lineHashes(pipe.result, mutationTargetPath, {
115
133
  content: pipe.originalNormalized,
116
134
  hashes: pipe.originalHashes,
117
135
  spans: span ? [{ start: span[0], end: span[1], replacementCount }] : undefined,
118
136
  });
137
+ } catch (error) {
138
+ const detail = error instanceof Error ? error.message : String(error);
139
+ throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
140
+ }
119
141
  const successInput = {
120
142
  path,
121
143
  originalNormalized: pipe.originalNormalized,