pi-hashline-edit-pro 4.2.10 → 4.3.0

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/src/batch.ts CHANGED
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { relative } from "node:path";
4
4
  import { readConfig, getDiffContextLines } from "./config";
5
- import { resolveEditTarget, throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
5
+ import { 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 {
@@ -16,14 +16,13 @@ import {
16
16
  type HEdit,
17
17
  type PlannedEdit,
18
18
  } from "./hashline";
19
- import { clearBoundaryBypass, markBoundaryNoop } from "./boundary-bypass";
20
- import { boundaryDedupWarning } from "./commit";
21
- import { adoptAnchors, markServed, servedForPath } from "./anchor-registry";
19
+ import { boundaryDedupNoopWarning, boundaryDedupWarning, dedupRowsFromFixes } from "./commit";
20
+ import { adoptAnchors, servedForPath } from "./anchor-registry";
22
21
  import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
23
22
  import { assertInsertReq, assertReq, normReq } from "./payload-contract";
24
23
  import { saveUndo } from "./replace-undo";
25
24
  import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
26
- import { buildServedMap, servedHashesFromDiff } from "./served";
25
+ import { serveRows, servedHashesFromDiff } from "./served";
27
26
  import { abortIf, assertLineLimit, errCode, isRec, splitLines } from "./utils";
28
27
  import { MAX_BYTES } from "./constants";
29
28
 
@@ -56,6 +55,7 @@ interface BatchBase {
56
55
  export interface BatchPiece {
57
56
  order: number;
58
57
  kind: BatchKind;
58
+ direction?: "before" | "after";
59
59
  start: number;
60
60
  end: number;
61
61
  fromHash: string;
@@ -63,14 +63,15 @@ export interface BatchPiece {
63
63
  newLines: string[];
64
64
  warnings: string[];
65
65
  autoFixes: number;
66
+ dedupAbove: string[];
67
+ dedupBelow: string[];
66
68
  noop: boolean;
67
- noopPayload?: string;
68
69
  foldedLines: number;
69
- bypassConsumed?: boolean;
70
70
  }
71
71
 
72
72
  export interface BatchMemberInput {
73
73
  kind: BatchKind;
74
+ direction?: "before" | "after";
74
75
  member: PlannedMember;
75
76
  targetPath: string;
76
77
  mutationTargetPath: string;
@@ -80,9 +81,13 @@ export interface BatchMemberInput {
80
81
  extraWarnings: string[];
81
82
  skipBoundaryDedup: boolean;
82
83
  strictBoundaryDedup: boolean;
83
- noopPayload?: string;
84
84
  foldedLines?: number;
85
- bypassConsumed?: boolean;
85
+ }
86
+
87
+ interface BatchFailure {
88
+ kind: BatchKind;
89
+ order: number;
90
+ code?: string;
86
91
  }
87
92
 
88
93
  interface BatchState {
@@ -100,7 +105,7 @@ interface BatchState {
100
105
  failures: number;
101
106
  failed: boolean;
102
107
  firstError?: unknown;
103
- poisonedBy?: string;
108
+ failure?: BatchFailure;
104
109
  warnings: string[];
105
110
  }
106
111
 
@@ -119,6 +124,30 @@ const MAX_TRACKED_BATCHES = 256;
119
124
  const plan = new Map<string, PlannedMember>();
120
125
  const batches = new Map<number, BatchState>();
121
126
  let nextBatchKey = 1;
127
+ const abortedMembers = new Map<string, { display: number; message: string }>();
128
+ const ABORTED_MEMBERS_LIMIT = 1024;
129
+
130
+ function markBatchMembersAborted(runtime: BatchState): void {
131
+ const message = abortedBatchMessage(runtime);
132
+ for (const id of runtime.memberIds) {
133
+ abortedMembers.delete(id);
134
+ abortedMembers.set(id, { display: runtime.display, message });
135
+ }
136
+ while (abortedMembers.size > ABORTED_MEMBERS_LIMIT) {
137
+ const oldest = abortedMembers.keys().next().value;
138
+ if (oldest === undefined) break;
139
+ abortedMembers.delete(oldest);
140
+ }
141
+ }
142
+
143
+ export function abortedBatchMessageFor(toolCallId: string): string | undefined {
144
+ const marked = abortedMembers.get(toolCallId);
145
+ if (marked !== undefined) return marked.message;
146
+ const member = plan.get(toolCallId);
147
+ if (!member) return undefined;
148
+ const runtime = batches.get(member.batchKey);
149
+ return runtime?.failed ? abortedBatchMessage(runtime) : undefined;
150
+ }
122
151
 
123
152
  export function batchMemberFor(toolCallId: string): PlannedMember | undefined {
124
153
  return plan.get(toolCallId);
@@ -127,6 +156,7 @@ export function batchMemberFor(toolCallId: string): PlannedMember | undefined {
127
156
  export function resetBatchStateForTests(): void {
128
157
  plan.clear();
129
158
  batches.clear();
159
+ abortedMembers.clear();
130
160
  nextBatchKey = 1;
131
161
  }
132
162
 
@@ -154,18 +184,6 @@ function anchorTargetFor(args: unknown): string | undefined {
154
184
  if (normalized.kind === "replace") return tryResolveEditTarget(normalized.removeFrom, normalized.removeTo);
155
185
  return tryResolveEditTarget(normalized.anchor);
156
186
  }
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
187
  async function inferredTargetFor(args: unknown, cwd: string, requirePath: boolean): Promise<string | undefined> {
170
188
  const normalized = normalizeEditArgs(args);
171
189
  if (!normalized) return undefined;
@@ -245,22 +263,10 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
245
263
  } else {
246
264
  finalGroups.push(...multi);
247
265
  }
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
- }
258
266
  let display = 0;
259
267
  for (const group of finalGroups) {
260
268
  display += 1;
261
269
  const key = nextBatchKey++;
262
- const matchingPoison = poison !== undefined && group[0]!.target === poison.target ? poison : undefined;
263
- const poisoned = matchingPoison !== undefined;
264
270
  batches.set(key, {
265
271
  display,
266
272
  target: group[0]!.target,
@@ -270,9 +276,8 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
270
276
  pieces: [],
271
277
  applied: 0,
272
278
  noops: 0,
273
- failures: poisoned ? 1 : 0,
274
- failed: poisoned,
275
- ...(matchingPoison ? { firstError: matchingPoison.error, poisonedBy: matchingPoison.callId } : {}),
279
+ failures: 0,
280
+ failed: false,
276
281
  warnings: [],
277
282
  });
278
283
  group.forEach((item, index) => {
@@ -309,7 +314,7 @@ function batchVerb(runtime: BatchState): string {
309
314
  return "replaced";
310
315
  }
311
316
  function batchHeader(member: PlannedMember): string {
312
- return member.total > 1 ? `batch ${member.display}:` : "batch:";
317
+ return `batch ${member.display}:`;
313
318
  }
314
319
 
315
320
  function formatBatchLines(start: number, end: number): string {
@@ -337,7 +342,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
337
342
  content: [
338
343
  {
339
344
  type: "text",
340
- text: member.total > 1 ? `In batch ${member.display}` : "In batch",
345
+ text: `In batch ${member.display}`,
341
346
  },
342
347
  ],
343
348
  details: {
@@ -357,12 +362,15 @@ export function withAbortSuffix(message: string, display: number): string {
357
362
  if (message.includes(suffix)) return message;
358
363
  return message.endsWith(".") ? `${message} ${suffix}` : `${message}. ${suffix}`;
359
364
  }
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
+
366
+ const ERROR_CODE_RE = /\[(E_[A-Z0-9_]+)\]/;
367
+
368
+ function errorCodeOf(error: unknown): string | undefined {
369
+ if (!(error instanceof Error)) return undefined;
370
+ const code = ERROR_CODE_RE.exec(error.message)?.[1];
371
+ return code === "E_OP_ABORTED" ? undefined : code;
365
372
  }
373
+
366
374
  export function noteBatchFailure(member: PlannedMember, error: unknown): void {
367
375
  const runtime = batches.get(member.batchKey);
368
376
  if (!runtime) return;
@@ -371,18 +379,39 @@ export function noteBatchFailure(member: PlannedMember, error: unknown): void {
371
379
  if (!runtime.failed) {
372
380
  runtime.failed = true;
373
381
  runtime.firstError = error;
382
+ const code = errorCodeOf(error);
383
+ runtime.failure = { kind: member.kind, order: member.order, ...(code !== undefined ? { code } : {}) };
374
384
  }
385
+ markBatchMembersAborted(runtime);
375
386
  }
376
387
 
377
- function batchAbortedError(runtime: BatchState, input?: BatchMemberInput): Error {
378
- restoreBatchBypasses(runtime, input);
379
- return new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted.`);
388
+ function firstFailureCause(runtime: BatchState): string | undefined {
389
+ const error = runtime.firstError;
390
+ if (!(error instanceof Error)) return undefined;
391
+ const suffix = ` Aborts batch ${runtime.display}.`;
392
+ const message = error.message.endsWith(suffix) ? error.message.slice(0, -suffix.length) : error.message;
393
+ const firstLine = message.split("\n")[0]?.trim() ?? "";
394
+ if (firstLine.length === 0) return undefined;
395
+ if (!firstLine.endsWith(":")) return firstLine;
396
+ const sentenceEnd = firstLine.lastIndexOf(". ");
397
+ return sentenceEnd >= 0 ? firstLine.slice(0, sentenceEnd + 1) : firstLine.slice(0, -1);
380
398
  }
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);
399
+
400
+ function abortedBatchMessage(runtime: BatchState): string {
401
+ const failure = runtime.failure;
402
+ if (failure?.code !== undefined) {
403
+ return `[E_OP_ABORTED] Batch ${runtime.display} aborted: [${failure.kind}] Call Nr ${failure.order} errored [${failure.code}]`;
384
404
  }
385
- if (input?.bypassConsumed && input.noopPayload) markBoundaryNoop(input.mutationTargetPath, input.noopPayload);
405
+ const cause = firstFailureCause(runtime);
406
+ return cause ? `[E_OP_ABORTED] Batch ${runtime.display} aborted: ${cause}` : `[E_OP_ABORTED] Batch ${runtime.display} aborted.`;
407
+ }
408
+
409
+ function batchAbortedError(runtime: BatchState): Error {
410
+ discardBatchState(runtime);
411
+ return new Error(abortedBatchMessage(runtime));
412
+ }
413
+ function discardBatchState(runtime: BatchState): void {
414
+ markBatchMembersAborted(runtime);
386
415
  }
387
416
 
388
417
  export async function ensureBatchBase(input: {
@@ -426,7 +455,7 @@ export async function ensureBatchBase(input: {
426
455
  export async function executeBatchMember(input: BatchMemberInput): Promise<TResult> {
427
456
  const runtime = batches.get(input.member.batchKey);
428
457
  if (!runtime) throw new Error(`[E_STALE_ANCHOR] Batch ${input.member.display} is no longer tracked. Call read for fresh anchors.`);
429
- if (runtime.failed) throw batchAbortedError(runtime, input);
458
+ if (runtime.failed) throw batchAbortedError(runtime);
430
459
  let base: BatchBase;
431
460
  try {
432
461
  base = await ensureBatchBase({
@@ -438,13 +467,13 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
438
467
  });
439
468
  } catch (error) {
440
469
  noteBatchFailure(input.member, error);
441
- restoreBatchBypasses(runtime, input);
470
+ discardBatchState(runtime);
442
471
  throw error;
443
472
  }
444
473
  if (input.mutationTargetPath !== input.member.target) {
445
474
  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.`);
446
475
  noteBatchFailure(input.member, error);
447
- restoreBatchBypasses(runtime, input);
476
+ discardBatchState(runtime);
448
477
  throw error;
449
478
  }
450
479
  const displayPath = runtime.paths?.displayPath ?? input.targetPath;
@@ -464,11 +493,11 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
464
493
  else if (error instanceof Error && error.message.startsWith("[E_BOUNDARY_STRICT]")) {
465
494
  const indexed = new Error(`edit #${input.member.order} strict boundary-dedup rejection: ${error.message}`);
466
495
  noteBatchFailure(input.member, indexed);
467
- restoreBatchBypasses(runtime, input);
496
+ discardBatchState(runtime);
468
497
  throw indexed;
469
498
  }
470
499
  noteBatchFailure(input.member, error);
471
- restoreBatchBypasses(runtime, input);
500
+ discardBatchState(runtime);
472
501
  throw error;
473
502
  }
474
503
  const start = planned.resolved.hash_bounds[0].line;
@@ -477,21 +506,23 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
477
506
  const baseLines = base.baseLines;
478
507
  const originalSlice = baseLines.slice(start - 1, end);
479
508
  const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
480
- const autoFixes = planned.autoFixes?.length ?? 0;
509
+ const fixes = planned.autoFixes ?? [];
510
+ const dedupRows = dedupRowsFromFixes(fixes);
481
511
  const piece: BatchPiece = {
482
512
  order: input.member.order,
483
513
  kind: input.kind,
514
+ ...(input.direction !== undefined ? { direction: input.direction } : {}),
484
515
  start,
485
516
  end,
486
517
  fromHash: input.hedit.hash_bounds[0].hash,
487
518
  toHash: input.hedit.hash_bounds[1].hash,
488
519
  newLines: [...newLines],
489
520
  warnings: [...input.extraWarnings, ...planned.warnings],
490
- autoFixes,
521
+ autoFixes: fixes.length,
522
+ dedupAbove: dedupRows.above,
523
+ dedupBelow: dedupRows.below,
491
524
  noop,
492
- ...(input.noopPayload !== undefined ? { noopPayload: input.noopPayload } : {}),
493
525
  foldedLines: input.foldedLines ?? 0,
494
- ...(input.bypassConsumed ? { bypassConsumed: true as const } : {}),
495
526
  };
496
527
  runtime.pieces.push(piece);
497
528
  if (input.kind === "replace") runtime.replaceCount += 1;
@@ -512,46 +543,100 @@ function composeBatchLines(baseContent: string, pieces: BatchPiece[]): string {
512
543
  return composed;
513
544
  }
514
545
 
546
+ function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
547
+ const byAnchor = new Map<number, BatchPiece[]>();
548
+ for (const piece of pieces) {
549
+ if (piece.kind !== "insert" || piece.start !== piece.end || piece.foldedLines === 0) continue;
550
+ const group = byAnchor.get(piece.start) ?? [];
551
+ group.push(piece);
552
+ byAnchor.set(piece.start, group);
553
+ }
554
+ const partnerOf = new Map<BatchPiece, BatchPiece>();
555
+ for (const group of byAnchor.values()) {
556
+ if (group.length !== 2) continue;
557
+ const [first, second] = group;
558
+ if (first.direction === undefined || second.direction === undefined || first.direction === second.direction) continue;
559
+ partnerOf.set(first, second);
560
+ partnerOf.set(second, first);
561
+ }
562
+ const merged: BatchPiece[] = [];
563
+ const consumed = new Set<BatchPiece>();
564
+ for (const piece of pieces) {
565
+ if (consumed.has(piece)) continue;
566
+ const partner = partnerOf.get(piece);
567
+ if (partner === undefined) {
568
+ merged.push(piece);
569
+ continue;
570
+ }
571
+ consumed.add(piece);
572
+ consumed.add(partner);
573
+ const before = piece.direction === "before" ? piece : partner;
574
+ const after = piece.direction === "before" ? partner : piece;
575
+ merged.push({
576
+ ...before,
577
+ order: Math.min(before.order, after.order),
578
+ newLines: [...before.newLines, ...after.newLines.slice(1)],
579
+ warnings: [...before.warnings, ...after.warnings],
580
+ autoFixes: before.autoFixes + after.autoFixes,
581
+ dedupAbove: [...before.dedupAbove, ...after.dedupAbove],
582
+ dedupBelow: [...before.dedupBelow, ...after.dedupBelow],
583
+ foldedLines: before.foldedLines + after.foldedLines - 1,
584
+ });
585
+ }
586
+ return merged;
587
+ }
588
+
589
+ function dedupCutNoops(pieces: BatchPiece[]): BatchPiece[] {
590
+ return pieces.filter((piece) => piece.noop && piece.autoFixes > 0).sort((a, b) => a.order - b.order);
591
+ }
592
+
593
+ function dedupCutNoopWarning(noops: BatchPiece[]): string {
594
+ return boundaryDedupNoopWarning(noops.map((piece) => piece.order), noops.reduce((sum, piece) => sum + piece.autoFixes, 0));
595
+ }
596
+
515
597
  async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise<TResult> {
516
598
  const runtime = batches.get(member.batchKey);
517
599
  if (!runtime || !runtime.base || !runtime.paths) throw new Error(`[E_STALE_ANCHOR] Batch ${member.display} is no longer tracked. Call read for fresh anchors.`);
518
600
  const base = runtime.base;
519
601
  const paths = runtime.paths;
520
602
  if (runtime.failed) throw batchAbortedError(runtime);
603
+ const executedOrders = new Set(runtime.pieces.map((piece) => piece.order));
521
604
  for (const id of runtime.memberIds) {
522
605
  const planned = plan.get(id);
523
606
  if (!planned) continue;
607
+ if (executedOrders.has(planned.order)) continue;
524
608
  try {
525
609
  const normalized = normReq(planned.args);
526
610
  if (planned.kind === "replace") assertReq(normalized);
527
611
  else assertInsertReq(normalized);
528
612
  } catch (error) {
529
- noteBatchFailure(member, error);
613
+ noteBatchFailure(planned, error);
530
614
  throw batchAbortedError(runtime);
531
615
  }
532
616
  }
533
617
  const appliedPieces = runtime.pieces.filter((piece) => !piece.noop);
534
618
  if (appliedPieces.length === 0) {
535
- for (const piece of runtime.pieces) {
536
- if (piece.autoFixes > 0 && piece.noopPayload !== undefined) markBoundaryNoop(runtime.target, piece.noopPayload);
537
- }
619
+ const dedupNoops = dedupCutNoops(runtime.pieces);
538
620
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
539
- return combinedNoop(paths.displayPath, member, runtime, snapshotId);
621
+ return combinedNoop(paths.displayPath, member, runtime, snapshotId, dedupNoops);
540
622
  }
541
- const ordered = [...appliedPieces].sort((a, b) => a.start - b.start);
623
+ const effectivePieces = mergeInsertPairs(appliedPieces);
624
+ const ordered = [...effectivePieces].sort((a, b) => a.start - b.start);
542
625
  for (let i = 1; i < ordered.length; i++) {
543
626
  const prev = ordered[i - 1]!;
544
627
  const current = ordered[i]!;
545
628
  if (current.start <= prev.end) {
546
- restoreBatchBypasses(runtime);
629
+ discardBatchState(runtime);
547
630
  throw new Error(`[E_BATCH_OVERLAP] Batch ${runtime.display} has overlapping ranges: ${formatBatchPiece(prev)} overlaps ${formatBatchPiece(current)}`);
548
631
  }
549
632
  }
550
- const composed = composeBatchLines(base.content, appliedPieces);
633
+ const composed = composeBatchLines(base.content, effectivePieces);
551
634
  const warnings = [...runtime.warnings];
552
635
  if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
553
- const dedupTotal = runtime.pieces.reduce((sum, piece) => sum + piece.autoFixes, 0);
636
+ const dedupTotal = effectivePieces.reduce((sum, piece) => sum + piece.autoFixes, 0);
554
637
  if (dedupTotal > 0) warnings.push(boundaryDedupWarning(dedupTotal));
638
+ const dedupNoops = dedupCutNoops(runtime.pieces);
639
+ if (dedupNoops.length > 0) warnings.push(dedupCutNoopWarning(dedupNoops));
555
640
  try {
556
641
  await throwIfStrictInput(dedupeWarnings(warnings));
557
642
  assertNotEmpty(base.content, composed);
@@ -561,13 +646,13 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
561
646
  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
647
  }
563
648
  } catch (error) {
564
- restoreBatchBypasses(runtime);
649
+ discardBatchState(runtime);
565
650
  if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
566
651
  throw error;
567
652
  }
568
653
  if (composed === base.content) {
569
654
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
570
- return combinedNoop(paths.displayPath, member, runtime, snapshotId);
655
+ return combinedNoop(paths.displayPath, member, runtime, snapshotId, dedupNoops);
571
656
  }
572
657
  abortIf(signal);
573
658
  let currentRaw: string;
@@ -575,14 +660,14 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
575
660
  currentRaw = await readFile(runtime.target, "utf-8");
576
661
  } catch (error) {
577
662
  if (errCode(error) !== "ENOENT") throw error;
578
- restoreBatchBypasses(runtime);
663
+ discardBatchState(runtime);
579
664
  throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file was deleted after the batch started.`);
580
665
  }
581
666
  if (toLF(stripBOM(currentRaw).text) !== base.content) {
582
- restoreBatchBypasses(runtime);
667
+ discardBatchState(runtime);
583
668
  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
669
  }
585
- const preflightSpans = appliedPieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
670
+ const preflightSpans = effectivePieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
586
671
  try {
587
672
  await lineHashes(composed, runtime.target, {
588
673
  content: base.content,
@@ -590,7 +675,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
590
675
  spans: preflightSpans,
591
676
  }, undefined, false, true);
592
677
  } catch (error) {
593
- restoreBatchBypasses(runtime);
678
+ discardBatchState(runtime);
594
679
  if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
595
680
  throw error;
596
681
  }
@@ -602,7 +687,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
602
687
  resultContent: composed,
603
688
  });
604
689
  if (!undo.persisted) {
605
- restoreBatchBypasses(runtime);
690
+ discardBatchState(runtime);
606
691
  throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${paths.displayPath}. Aborts batch ${runtime.display}.`);
607
692
  }
608
693
  try {
@@ -610,13 +695,12 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
610
695
  await writeAtomic(paths.absolutePath, base.bom + restoreEndings(composed, base.ending), base.identity);
611
696
  } catch (error) {
612
697
  await undo.restore();
613
- restoreBatchBypasses(runtime);
698
+ discardBatchState(runtime);
614
699
  if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
615
700
  throw error;
616
701
  }
617
- clearBoundaryBypass(runtime.target);
618
702
  const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
619
- const spans = appliedPieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
703
+ const spans = effectivePieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length, dedupAbove: piece.dedupAbove, dedupBelow: piece.dedupBelow }));
620
704
  let resultHashes: string[];
621
705
  try {
622
706
  resultHashes = await lineHashes(composed, runtime.target, {
@@ -636,6 +720,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
636
720
  const gross = Math.max(0, piece.newLines.length - piece.autoFixes);
637
721
  added += piece.kind === "insert" ? Math.max(0, gross - piece.foldedLines) : gross;
638
722
  }
723
+ const dedupAboveRows = ordered.flatMap((piece) => piece.dedupAbove);
724
+ const dedupBelowRows = ordered.flatMap((piece) => piece.dedupBelow);
639
725
  const header = batchHeader(member);
640
726
  const changed = buildChanged(
641
727
  {
@@ -654,8 +740,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
654
740
  addedLines: added,
655
741
  removedLines: removed,
656
742
  },
657
- boundaryDedupAbove: [],
658
- boundaryDedupBelow: [],
743
+ boundaryDedupAbove: dedupAboveRows,
744
+ boundaryDedupBelow: dedupBelowRows,
659
745
  spans,
660
746
  },
661
747
  batchVerb(runtime),
@@ -664,7 +750,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
664
750
  changed.details.diff = `${header}\n${changed.details.diff}`;
665
751
  changed.details.diffLineNumbers?.unshift(undefined);
666
752
  try {
667
- markServed(runtime.target, buildServedMap(resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff)), new Set(resultHashes));
753
+ serveRows(runtime.target, resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff));
668
754
  } catch (error) {
669
755
  console.error("Failed to mark batch diff served:", error);
670
756
  }
@@ -674,8 +760,10 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
674
760
  return changed;
675
761
  }
676
762
 
677
- async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined): Promise<TResult> {
763
+ async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined, dedupNoops: BatchPiece[]): Promise<TResult> {
678
764
  const executed = runtime.applied + runtime.noops;
765
+ const warnings = [...runtime.warnings];
766
+ if (dedupNoops.length > 0) warnings.push(dedupCutNoopWarning(dedupNoops));
679
767
  const noop = buildNoop(
680
768
  {
681
769
  path,
@@ -687,7 +775,7 @@ async function combinedNoop(path: string, member: PlannedMember, runtime: BatchS
687
775
  addedLines: 0,
688
776
  removedLines: 0,
689
777
  },
690
- warnings: dedupeWarnings(runtime.warnings),
778
+ warnings: dedupeWarnings(warnings),
691
779
  boundaryRemovedLines: 0,
692
780
  },
693
781
  "Batch",
package/src/commit.ts CHANGED
@@ -8,11 +8,10 @@ import { saveUndo } from "./replace-undo";
8
8
  import { getDiffContextLines } from "./config";
9
9
  import { safeSnapId } from "./file-reader";
10
10
  import { writeAtomic } from "./fs-write";
11
- import { servedHashesFromDiff, buildServedMap } from "./served";
12
- import { lineHashes } from "./hashline";
13
- import { hashSpan } from "./replace";
11
+ import { servedHashesFromDiff, serveRows } from "./served";
12
+ import { lineHashes, type AutoFix } from "./hashline";
13
+ import { spanForEdit } from "./replace";
14
14
  import { restoreEndings, stripBOM, toLF } from "./normalize";
15
- import { markServed as markServedScoped } from "./anchor-registry";
16
15
  export interface CommitMeta {
17
16
  editAnchors?: [string, string];
18
17
  path: string;
@@ -22,10 +21,7 @@ export interface CommitMeta {
22
21
  verb?: string;
23
22
  noopNoun?: string;
24
23
  prefixWarnings?: string[];
25
- appliedWarnings?: string[];
26
24
  foldedAnchorLines?: number;
27
- onApplied?: () => void;
28
- onNoopDedup?: () => void;
29
25
  }
30
26
 
31
27
  export function boundaryDedupWarning(count: number): string {
@@ -34,6 +30,21 @@ export function boundaryDedupWarning(count: number): string {
34
30
  return `Boundary dedup: ${noun} not added again (see ${DEDUP_ANCHOR}${HASH_SEP} ${row}).`;
35
31
  }
36
32
 
33
+ export function boundaryDedupNoopWarning(orders: number[], removedLines: number): string {
34
+ const noun = removedLines === 1 ? "1 line" : `${removedLines} lines`;
35
+ if (orders.length === 1) {
36
+ return `Boundary dedup: edit #${orders[0]!} produced no change (${noun} not added again).`;
37
+ }
38
+ return `Boundary dedup: edits ${orders.map((order) => `#${order}`).join(", ")} produced no change (${noun} not added again).`;
39
+ }
40
+
41
+ export function dedupRowsFromFixes(fixes: AutoFix[]): { removedTexts: string[]; above: string[]; below: string[] } {
42
+ const sorted = [...fixes].sort((a, b) => a.removedLineIndex - b.removedLineIndex);
43
+ const above = sorted.filter((fix) => fix.kind === "leading" || fix.kind === "last-new-before").map((fix) => fix.removedLine);
44
+ const below = sorted.filter((fix) => fix.kind === "trailing" || fix.kind === "first-new-after").map((fix) => fix.removedLine);
45
+ return { removedTexts: sorted.map((fix) => fix.removedLine), above, below };
46
+ }
47
+
37
48
  export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
38
49
  const { path, absolutePath, mutationTargetPath, signal } = meta;
39
50
  const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
@@ -41,7 +52,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
41
52
 
42
53
  if (pipe.result === pipe.originalNormalized) {
43
54
  const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
44
- if (pipe.hadBoundaryDedup) meta.onNoopDedup?.();
45
55
  return buildNoop(
46
56
  {
47
57
  path,
@@ -60,7 +70,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
60
70
  );
61
71
  }
62
72
 
63
- warnings.push(...(meta.appliedWarnings ?? []));
64
73
  if (pipe.hadUtf8DecodeErrors) {
65
74
  warnings.push(
66
75
  "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
@@ -110,7 +119,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
110
119
  await undo.restore();
111
120
  throw error;
112
121
  }
113
- meta.onApplied?.();
114
122
  const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
115
123
 
116
124
  const editMeta: RMeta = {
@@ -122,18 +130,14 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
122
130
  removedLines: pipe.totalRemovedLines,
123
131
  };
124
132
 
125
- const span = meta.editAnchors ? hashSpan(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1]) : undefined;
126
- const resultCount = splitLines(pipe.result).length;
127
- const replacementCount = span ? resultCount - (pipe.originalHashes.length - (span[1] - span[0] + 1)) : 0;
133
+ const span = meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined;
128
134
  let resultHashes: string[];
129
135
  try {
130
- resultHashes = pipe.result === pipe.originalNormalized
131
- ? pipe.originalHashes
132
- : await lineHashes(pipe.result, mutationTargetPath, {
133
- content: pipe.originalNormalized,
134
- hashes: pipe.originalHashes,
135
- spans: span ? [{ start: span[0], end: span[1], replacementCount }] : undefined,
136
- });
136
+ resultHashes = await lineHashes(pipe.result, mutationTargetPath, {
137
+ content: pipe.originalNormalized,
138
+ hashes: pipe.originalHashes,
139
+ spans: span ? [span] : undefined,
140
+ });
137
141
  } catch (error) {
138
142
  const detail = error instanceof Error ? error.message : String(error);
139
143
  throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
@@ -149,15 +153,11 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
149
153
  editMeta,
150
154
  boundaryDedupAbove: pipe.boundaryDedupAbove,
151
155
  boundaryDedupBelow: pipe.boundaryDedupBelow,
152
- ...(span ? { spans: [{ start: span[0], end: span[1], replacementCount }] } : {}),
156
+ ...(span ? { spans: [span] } : {}),
153
157
  };
154
158
  const changed = buildChanged(successInput, meta.verb, await getDiffContextLines());
155
159
  if (changed.details.diff) {
156
- markServedScoped(
157
- mutationTargetPath,
158
- buildServedMap(resultHashes, splitLines(pipe.result), servedHashesFromDiff(changed.details.diff)),
159
- new Set(resultHashes),
160
- );
160
+ serveRows(mutationTargetPath, resultHashes, splitLines(pipe.result), servedHashesFromDiff(changed.details.diff));
161
161
  }
162
162
  return changed;
163
163
  }
package/src/config-ui.ts CHANGED
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { readConfig, type Config } from "./config";
4
4
 
5
- export type ConfigToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
5
+ export type ConfigToggleKey = "autoRead" | "autoReadAll" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
@@ -18,6 +18,7 @@ export interface ConfigRow {
18
18
  export function configRows(config: Config): ConfigRow[] {
19
19
  return [
20
20
  { key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
21
+ { key: "autoReadAll", label: "Auto-read all", hint: "Attach every non-ignored file with anchors on the first turn", enabled: config.autoReadAll === true },
21
22
  { key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
22
23
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
23
24
  { key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },