pi-hashline-edit-pro 4.2.9 → 4.2.11

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
@@ -94,13 +94,13 @@ Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before
94
94
 
95
95
  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`.
96
96
 
97
- 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
+ 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, the result states how many lines were removed; sending the same replacement again goes through the same dedup. To apply such lines literally, turn Boundary dedup off in `/hashline-config`.
98
98
 
99
99
  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`.
100
100
 
101
- Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run solo, one after another. A solo edit commits before its result returns; a batch validates every call against the pre-batch state and commits once, during 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. Nothing commits at turn end.
101
+ Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run solo, one after another. A solo edit commits before its result returns; a batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N` and the batch's last call shows the combined diff, with one undo reverting the whole batch. If the batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits at turn end. A batch member accepts the same request shapes and auto-fixes as a solo call. A member whose boundary dedup cuts it to a noop contributes no hunk to the combined diff: the batch warning names it instead of pointing at `dedup│` rows. The `dedup│` rows in a batch diff cover its applied members only.
102
102
  Tools in one message run concurrently, so a `read` or shell `cat` issued alongside an edit can observe the pre-commit state; verify in the next message with the post-edit diff or a fresh `read`.
103
- Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. A resolvable failure aborts its batch-mates, while an anchor that resolves nowhere cannot join a batch: same-message [resolvable-fail + resolvable-valid] on one file fails fast with `[E_OP_ABORTED]`, but the same pair split across messages runs as two solos (fail, then apply). 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-message 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.
103
+ Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails aborts its batch-mates with `[E_OP_ABORTED]`. A call whose anchors resolve nowhere never joins a batch: it runs solo and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, join their file's batch and abort it instead of applying partially. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). 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.
104
104
 
105
105
  ### insert
106
106
 
@@ -114,7 +114,7 @@ Batched calls must target disjoint ranges; overlapping ranges, or any failing ca
114
114
 
115
115
  Lines are applied literally: nothing is removed, and a line that duplicates its neighbor is kept. `replace`'s boundary anti-duplication never runs for `insert`. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
116
116
 
117
- The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), line endings and BOMs survive, and an applied insert clears a pending boundary bypass.
117
+ The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), and line endings and BOMs survive.
118
118
 
119
119
  ### anchor_grep
120
120
 
@@ -154,7 +154,7 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
154
154
 
155
155
  Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
156
156
 
157
- After `replace`, `insert`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch:` line (`batch N:` when several files batch); earlier calls reply `In batch` (`In batch N` when several files batch). The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
157
+ After `replace`, `insert`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
158
158
 
159
159
  Auto-read keeps the same 50KB and 2000-line budget as `read`. Change it in `/hashline-config`; both settings persist across sessions. The post-edit diff shows 1 surrounding line by default; change Diff context in `/hashline-config` (0-10, needs Auto-read) to show more or fewer.
160
160
 
@@ -199,7 +199,7 @@ The table is curated for tokenizers, not for humans. Every anchor is the concate
199
199
 
200
200
  Each line also carries a content checksum. The line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm). The canonicalization keeps the checksum stable across editor-save cycles that add or remove trailing whitespace. A line over 500 bytes is hashed from its first 500 bytes.
201
201
 
202
- Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) keyed by content checksum, so resume-after-restart and cross-session edits reuse ownership instead of minting duplicates. Each session also appends an ownership log (`allocate`/`free`/`clear` events) to a sidecar file under `~/.config/pi-hashline-edit-pro/sessions/`; the fold of that log is the session's source of truth, sidecars whose session file is gone are garbage-collected at startup, and the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use.
202
+ Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) keyed by content checksum, so resume-after-restart and cross-session edits reuse ownership instead of minting duplicates. Each session also appends an ownership log (`allocate`/`free`/`clear` events) to a sidecar file under `~/.config/pi-hashline-edit-pro/sessions/`; the fold of that log is the session's source of truth, sidecars whose session file is gone are garbage-collected at startup (never the sidecar of a session that is currently loaded in this process), and the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use.
203
203
 
204
204
  When a range is edited, the mapping between old and new content is computed per span: lines whose content is unchanged keep their allocated anchors, anchors of removed lines are freed, and every genuinely new line is minted a fresh anchor. Anchors are never assigned by matching content; only positional survival across an edit preserves one.
205
205
 
@@ -233,15 +233,14 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
233
233
  | `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
234
234
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
235
235
  | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
236
- | `[W_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
236
  | `[E_BOUNDARY_STRICT]` | Strict boundary dedup rejected the edit because replacement lines re-include edge lines; resend without those lines. |
238
237
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
239
238
  | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
240
239
  | `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
241
240
  | `[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
241
  | `[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_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. Retry with disjoint ranges. |
244
- | `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. |
242
+ | `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
243
+ | `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
245
244
  | `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
246
245
 
247
246
  ## Troubleshooting
package/index.ts CHANGED
@@ -22,7 +22,6 @@ import {
22
22
  import { loadHashStore, persistSnapshot, pruneMissing } from "./src/hash-store";
23
23
  import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, markServed as markServedScoped, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
24
24
  import { buildServedMap } from "./src/served";
25
- import { clearBoundaryBypass } from "./src/boundary-bypass";
26
25
  import { finalizeTurn, planAssistantMessage } from "./src/batch";
27
26
  import { currentEditFlags } from "./src/edit-common";
28
27
  import { HashlineConfigOverlay } from "./src/config-ui";
@@ -167,7 +166,6 @@ export default function (pi: ExtensionAPI): void {
167
166
  resolvedPath = (await resolveInCwd(writtenPath, ctx.cwd)).resolved;
168
167
  freeAnchors(resolvedPath);
169
168
  await clearUndo(resolvedPath);
170
- clearBoundaryBypass(resolvedPath);
171
169
  } catch (error) {
172
170
  console.error("Failed to clear undo after write:", error);
173
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.2.9",
3
+ "version": "4.2.11",
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,2 +1,3 @@
1
1
  - `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or any served `anchor│content` row. Empty file: `read` shows one `anchor│` row — insert `after` it.
2
- - `insert`: same-file calls in one message join the file's batch: earlier calls reply `In batch` (`In batch N` when several files batch), the last call shows the combined diff.
2
+ - `insert`: same-file calls in one message join the file's batch: earlier calls reply `In batch N`, the last call shows the combined diff.
3
+ - `insert`: a batch may pair one `before` and one `after` on the same anchor line; the pair composes into a single insertion. Any other same-line pair is an overlap.
@@ -1 +1 @@
1
- Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from read output. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes and no embedded newlines; `[]` deletes the range. Every line in the range must match what was last shown; if the file drifted, the edit is refused and the current range is returned with fresh anchors, so retry without re-reading. Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch` (`In batch N` when several files batch) and the last call shows the combined diff, with one undo for the whole batch.
1
+ Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from read output. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes and no embedded newlines; `[]` deletes the range. Every line in the range must match what was last shown; if the file drifted, the edit is refused and the current range is returned with fresh anchors, so retry without re-reading. Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
@@ -1,5 +1,5 @@
1
1
  import { chmod, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
- import { appendFileSync, chmodSync } from "node:fs";
2
+ import { appendFileSync, chmodSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -163,6 +163,7 @@ export const SIDECAR_HEADER_BYTES = 64 * 1024;
163
163
  const SIDECAR_HEADER_CHUNK = 4096;
164
164
  let currentKey: string | undefined;
165
165
  const sidecarByKey = new Map<string, string>();
166
+ const sessionFileByKey = new Map<string, string>();
166
167
  const pendingInits = new Map<string, Promise<void>>();
167
168
  const loadTokens = new Map<string, object>();
168
169
  const registries = new Map<string, SessionState>();
@@ -368,6 +369,7 @@ async function ensureRegistryForKey(key: string, sessionFile: string | undefined
368
369
  registries.set(key, newSessionState(key));
369
370
  return;
370
371
  }
372
+ sessionFileByKey.set(key, sessionFile);
371
373
  await loadRegistryState(key, sessionFile, token);
372
374
  })();
373
375
  pendingInits.set(key, promise);
@@ -423,12 +425,27 @@ function current(): SessionState | undefined {
423
425
  return registries.get(key);
424
426
  }
425
427
 
428
+ function sidecarNeedsSessionRecord(sidecar: string): boolean {
429
+ try {
430
+ return statSync(sidecar).size === 0;
431
+ } catch {
432
+ return true;
433
+ }
434
+ }
435
+
436
+ function ensureSidecarSessionRecord(key: string, sidecar: string): void {
437
+ const sessionFile = sessionFileByKey.get(key);
438
+ if (sessionFile === undefined || !sidecarNeedsSessionRecord(sidecar)) return;
439
+ appendEvent({ kind: "session", sessionFile } satisfies RegistryEvent);
440
+ }
441
+
426
442
  function appendEvent(event: RegistryEvent): void {
427
443
  const key = activeKey();
428
444
  if (!key) return;
429
445
  const sidecar = sidecarByKey.get(key);
430
446
  if (!sidecar) return;
431
447
  try {
448
+ if (event.kind !== "session") ensureSidecarSessionRecord(key, sidecar);
432
449
  appendFileSync(sidecar, JSON.stringify(event) + "\n", "utf-8");
433
450
  if (process.platform !== "win32") {
434
451
  try { chmodSync(sidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
@@ -880,6 +897,7 @@ export function adoptAnchors(path: string, entries: Map<string, string>): void {
880
897
  export function resetRegistryForTests(): void {
881
898
  currentKey = undefined;
882
899
  sidecarByKey.clear();
900
+ sessionFileByKey.clear();
883
901
  pendingInits.clear();
884
902
  loadTokens.clear();
885
903
  registries.clear();
@@ -924,9 +942,17 @@ export function releaseRegistrySession(key: string): void {
924
942
  if (currentKey === key) currentKey = undefined;
925
943
  pendingInits.delete(key);
926
944
  sidecarByKey.delete(key);
945
+ sessionFileByKey.delete(key);
927
946
  registries.delete(key);
928
947
  }
929
948
 
949
+ function isClaimedSidecar(sidecar: string): boolean {
950
+ for (const claimed of sidecarByKey.values()) {
951
+ if (claimed === sidecar) return true;
952
+ }
953
+ return false;
954
+ }
955
+
930
956
  export async function gcRegistrySidecars(): Promise<void> {
931
957
  let names: string[];
932
958
  try {
@@ -948,6 +974,7 @@ export async function gcRegistrySidecars(): Promise<void> {
948
974
  }
949
975
  if (!name.endsWith(SIDECAR_SUFFIX)) continue;
950
976
  const sidecar = join(sessionClaimsDir(), name);
977
+ if (isClaimedSidecar(sidecar)) continue;
951
978
  try {
952
979
  const sessionFile = await readSidecarSessionFile(sidecar);
953
980
  if (sessionFile === undefined) continue;
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,8 +16,7 @@ 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";
19
+ import { boundaryDedupNoopWarning, boundaryDedupWarning, dedupRowsFromFixes } from "./commit";
21
20
  import { adoptAnchors, markServed, servedForPath } from "./anchor-registry";
22
21
  import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
23
22
  import { assertInsertReq, assertReq, normReq } from "./payload-contract";
@@ -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),
@@ -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
@@ -9,7 +9,7 @@ import { getDiffContextLines } from "./config";
9
9
  import { safeSnapId } from "./file-reader";
10
10
  import { writeAtomic } from "./fs-write";
11
11
  import { servedHashesFromDiff, buildServedMap } from "./served";
12
- import { lineHashes } from "./hashline";
12
+ import { lineHashes, type AutoFix } from "./hashline";
13
13
  import { hashSpan } from "./replace";
14
14
  import { restoreEndings, stripBOM, toLF } from "./normalize";
15
15
  import { markServed as markServedScoped } from "./anchor-registry";
@@ -22,10 +22,7 @@ export interface CommitMeta {
22
22
  verb?: string;
23
23
  noopNoun?: string;
24
24
  prefixWarnings?: string[];
25
- appliedWarnings?: string[];
26
25
  foldedAnchorLines?: number;
27
- onApplied?: () => void;
28
- onNoopDedup?: () => void;
29
26
  }
30
27
 
31
28
  export function boundaryDedupWarning(count: number): string {
@@ -34,6 +31,21 @@ export function boundaryDedupWarning(count: number): string {
34
31
  return `Boundary dedup: ${noun} not added again (see ${DEDUP_ANCHOR}${HASH_SEP} ${row}).`;
35
32
  }
36
33
 
34
+ export function boundaryDedupNoopWarning(orders: number[], removedLines: number): string {
35
+ const noun = removedLines === 1 ? "1 line" : `${removedLines} lines`;
36
+ if (orders.length === 1) {
37
+ return `Boundary dedup: edit #${orders[0]!} produced no change (${noun} not added again).`;
38
+ }
39
+ return `Boundary dedup: edits ${orders.map((order) => `#${order}`).join(", ")} produced no change (${noun} not added again).`;
40
+ }
41
+
42
+ export function dedupRowsFromFixes(fixes: AutoFix[]): { removedTexts: string[]; above: string[]; below: string[] } {
43
+ const sorted = [...fixes].sort((a, b) => a.removedLineIndex - b.removedLineIndex);
44
+ const above = sorted.filter((fix) => fix.kind === "leading" || fix.kind === "last-new-before").map((fix) => fix.removedLine);
45
+ const below = sorted.filter((fix) => fix.kind === "trailing" || fix.kind === "first-new-after").map((fix) => fix.removedLine);
46
+ return { removedTexts: sorted.map((fix) => fix.removedLine), above, below };
47
+ }
48
+
37
49
  export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
38
50
  const { path, absolutePath, mutationTargetPath, signal } = meta;
39
51
  const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
@@ -41,7 +53,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
41
53
 
42
54
  if (pipe.result === pipe.originalNormalized) {
43
55
  const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
44
- if (pipe.hadBoundaryDedup) meta.onNoopDedup?.();
45
56
  return buildNoop(
46
57
  {
47
58
  path,
@@ -60,7 +71,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
60
71
  );
61
72
  }
62
73
 
63
- warnings.push(...(meta.appliedWarnings ?? []));
64
74
  if (pipe.hadUtf8DecodeErrors) {
65
75
  warnings.push(
66
76
  "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
@@ -110,7 +120,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
110
120
  await undo.restore();
111
121
  throw error;
112
122
  }
113
- meta.onApplied?.();
114
123
  const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
115
124
 
116
125
  const editMeta: RMeta = {
package/src/insert.ts CHANGED
@@ -3,7 +3,7 @@ import { Type } from "typebox";
3
3
  import { constants } from "node:fs";
4
4
  import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
5
5
  import { commitEdit } from "./commit";
6
- import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure, suffixPoisonCause } from "./batch";
6
+ import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure } from "./batch";
7
7
  import { readNormFile, type NormFile } from "./file-reader";
8
8
  import { MAX_HASH_LINES, parseHashRef, resEdit, resolveAnchorLine, type Anchor, type HEdit } from "./hashline";
9
9
  import { stripAnchorRow } from "./hashline/resolve";
@@ -11,7 +11,6 @@ import { withAnchorSession } from "./anchor-registry";
11
11
  import { loadP, loadGuide } from "./prompts";
12
12
  import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
13
13
  import { decodeStringArray, isRec, splitLines } from "./utils";
14
- import { clearBoundaryBypass } from "./boundary-bypass";
15
14
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
16
15
  import type { RPreview, RRState } from "./replace-render";
17
16
  export { assertInsertReq, type InsertReq };
@@ -189,7 +188,6 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
189
188
  }).catch((error: unknown) => {
190
189
  const member = batchMemberFor(_toolCallId);
191
190
  if (member) noteBatchFailure(member, error);
192
- else suffixPoisonCause(_toolCallId, error);
193
191
  throw error;
194
192
  });
195
193
  let ref: Anchor;
@@ -218,6 +216,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
218
216
  }
219
217
  return executeBatchMember({
220
218
  kind: "insert",
219
+ direction: req.direction,
221
220
  member,
222
221
  targetPath,
223
222
  mutationTargetPath,
@@ -251,7 +250,6 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
251
250
  noopNoun: "Insertion",
252
251
  foldedAnchorLines: anchorLine === undefined ? 0 : 1,
253
252
  prefixWarnings: [...anchorWarnings, ...insertWarnings],
254
- onApplied: () => clearBoundaryBypass(mutationTargetPath),
255
253
  });
256
254
  });
257
255
  });
@@ -6,7 +6,7 @@ import {
6
6
  HASH_SEP,
7
7
  changedRange,
8
8
  } from "./hashline";
9
- import { MAX_DIFF_INPUT_BYTES } from "./constants";
9
+ import { MAX_DIFF_INPUT_BYTES, DEDUP_ANCHOR } from "./constants";
10
10
  import { splitLines } from "./utils";
11
11
  import {
12
12
  detectEnding,
@@ -22,6 +22,8 @@ export interface DiffSpan {
22
22
  start: number;
23
23
  end: number;
24
24
  replacementCount: number;
25
+ dedupAbove?: string[];
26
+ dedupBelow?: string[];
25
27
  }
26
28
 
27
29
  interface PlacedSpan {
@@ -29,6 +31,16 @@ interface PlacedSpan {
29
31
  oldEnd: number;
30
32
  newStart: number;
31
33
  newEnd: number;
34
+ dedupAbove?: string[];
35
+ dedupBelow?: string[];
36
+ }
37
+
38
+ export function fmtDedupRow(line: string): string {
39
+ const row = `${DEDUP_ANCHOR}${HASH_SEP}${line}`;
40
+ if (Buffer.byteLength(row, "utf-8") <= DEFAULT_MAX_BYTES) return row;
41
+ const size = formatSize(Buffer.byteLength(row, "utf-8"));
42
+ const limit = formatSize(DEFAULT_MAX_BYTES);
43
+ return `${DEDUP_ANCHOR}${HASH_SEP}[Row is ${size}, exceeds ${limit}; content not shown. Use read to see the full line.]`;
32
44
  }
33
45
 
34
46
  function fmtDiffLine(
@@ -155,7 +167,7 @@ function placeSpans(
155
167
  if (span.replacementCount === 0) {
156
168
  if (newEnd !== newStart - 1) return undefined;
157
169
  } else if (newEnd >= newLen) return undefined;
158
- placed.push({ oldStart: span.start, oldEnd: span.end, newStart, newEnd });
170
+ placed.push({ oldStart: span.start, oldEnd: span.end, newStart, newEnd, dedupAbove: span.dedupAbove, dedupBelow: span.dedupBelow });
159
171
  offset += span.replacementCount - spanLen;
160
172
  removedSum += spanLen;
161
173
  addedSum += span.replacementCount;
@@ -184,7 +196,7 @@ function trimPlacedSpans(
184
196
  newEnd -= 1;
185
197
  }
186
198
  if (oldStart > oldEnd && newStart > newEnd) continue;
187
- out.push({ oldStart, oldEnd, newStart, newEnd });
199
+ out.push({ oldStart, oldEnd, newStart, newEnd, dedupAbove: span.dedupAbove, dedupBelow: span.dedupBelow });
188
200
  }
189
201
  return out;
190
202
  }
@@ -198,7 +210,7 @@ function genSpanDiff(
198
210
  maxLineBytes: number,
199
211
  maxBytes: number,
200
212
  spans: DiffSpan[],
201
- ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number | undefined)[] } | undefined {
213
+ ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number | undefined)[]; dedupEmitted: boolean } | undefined {
202
214
  const oldLines = splitLines(oldContent);
203
215
  const newLines = splitLines(newContent);
204
216
  if (oldHashes.length !== oldLines.length || newHashes.length !== newLines.length) return undefined;
@@ -220,6 +232,7 @@ function genSpanDiff(
220
232
  return undefined;
221
233
  }
222
234
  const output: string[] = [];
235
+ let dedupEmitted = false;
223
236
  const lineNumbers: (number | undefined)[] = [];
224
237
  let firstChangedLine: number | undefined;
225
238
  let outBytes = 0;
@@ -310,12 +323,24 @@ function genSpanDiff(
310
323
  emitGap(newPos, gapLen, spanIdx === 0 ? "leading" : "middle");
311
324
  if (stopped) break;
312
325
  if (firstChangedLine === undefined) firstChangedLine = span.newStart + 1;
326
+ for (const dedup of span.dedupAbove ?? []) {
327
+ if (stopped) break;
328
+ dedupEmitted = true;
329
+ output.push(fmtDedupRow(dedup));
330
+ lineNumbers.push(undefined);
331
+ }
313
332
  for (let oldIdx = span.oldStart; oldIdx <= span.oldEnd && !stopped; oldIdx++) {
314
333
  emitRow("-", oldLines[oldIdx]!, oldHashes[oldIdx], oldIdx + 1);
315
334
  }
316
335
  for (let newIdx = span.newStart; newIdx <= span.newEnd && !stopped; newIdx++) {
317
336
  emitRow("+", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
318
337
  }
338
+ for (const dedup of span.dedupBelow ?? []) {
339
+ if (stopped) break;
340
+ dedupEmitted = true;
341
+ output.push(fmtDedupRow(dedup));
342
+ lineNumbers.push(undefined);
343
+ }
319
344
  oldPos = span.oldEnd + 1;
320
345
  newPos = span.newEnd + 1;
321
346
  }
@@ -329,7 +354,7 @@ function genSpanDiff(
329
354
  output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
330
355
  lineNumbers.push(undefined);
331
356
  }
332
- return { diff: output.join("\n"), firstChangedLine, lineNumbers };
357
+ return { diff: output.join("\n"), firstChangedLine, lineNumbers, dedupEmitted };
333
358
  }
334
359
 
335
360
  function overDiffInputLimit(oldContent: string, newContent: string): boolean {
@@ -344,14 +369,14 @@ export function genDiff(
344
369
  oldContentHashes?: string[],
345
370
  limits?: DiffLimits,
346
371
  spans?: DiffSpan[],
347
- ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
372
+ ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[]; spanDedupEmitted?: boolean } {
348
373
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
349
374
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
350
375
  if (spans && newContentHashes && oldContentHashes) {
351
376
  const anchored = genSpanDiff(oldContent, newContent, contextLines, newContentHashes, oldContentHashes, maxLineBytes, maxBytes, spans);
352
377
  if (anchored) {
353
378
  const diff = disambiguateDuplicateAnchors(anchored.diff);
354
- return { diff, firstChangedLine: anchored.firstChangedLine, lineNumbers: anchored.lineNumbers };
379
+ return { diff, firstChangedLine: anchored.firstChangedLine, lineNumbers: anchored.lineNumbers, ...(anchored.dedupEmitted ? { spanDedupEmitted: true as const } : {}) };
355
380
  }
356
381
  }
357
382
  if (!limits?.unlimited && overDiffInputLimit(oldContent, newContent)) {
@@ -1,6 +1,7 @@
1
1
  import { Markdown, Text } from "@earendil-works/pi-tui";
2
2
  import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
3
3
  import type { ReplaceDetails } from "./replace";
4
+ import { abortedBatchMessageFor } from "./batch";
4
5
  import { withLineNumbers } from "./utils";
5
6
  import { isDedupRow } from "./replace-response";
6
7
  import { getPreviewInput } from "./payload-contract";
@@ -66,6 +67,21 @@ export function fmtResult(diff: string, theme: FgT): string {
66
67
  return colorLines(diff.split("\n"), theme).join("\n");
67
68
  }
68
69
 
70
+ export function highlightBatchRefs(text: string, theme: FgT): string {
71
+ const pattern = /\b([Bb]atch \d+)\b/g;
72
+ const sections: string[] = [];
73
+ let cursor = 0;
74
+ for (;;) {
75
+ const match = pattern.exec(text);
76
+ if (!match) break;
77
+ if (match.index > cursor) sections.push(theme.fg("error", text.slice(cursor, match.index)));
78
+ sections.push(theme.fg("warning", match[0]));
79
+ cursor = match.index + match[0].length;
80
+ }
81
+ if (cursor < text.length) sections.push(theme.fg("error", text.slice(cursor)));
82
+ return sections.join("");
83
+ }
84
+
69
85
  export function fmtCall(
70
86
  args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string } | undefined,
71
87
  state: RRState,
@@ -144,6 +160,14 @@ function extractSummary(text: string | undefined): string | undefined {
144
160
  return summary.length > 0 ? summary : undefined;
145
161
  }
146
162
 
163
+ const BATCH_HEADER_RE = /^batch \d+:\n/;
164
+
165
+ function splitBatchHeader(diff: string, lineNumbers: (number | undefined)[] | undefined): { header: string | undefined; body: string; lineNumbers: (number | undefined)[] | undefined } {
166
+ const match = diff.match(BATCH_HEADER_RE);
167
+ if (!match) return { header: undefined, body: diff, lineNumbers };
168
+ return { header: match[0]!.slice(0, -1), body: diff.slice(match[0].length), lineNumbers: lineNumbers?.slice(1) };
169
+ }
170
+
147
171
  export function buildAppliedText(
148
172
  text: string | undefined,
149
173
  details: ReplaceDetails | undefined,
@@ -154,8 +178,9 @@ export function buildAppliedText(
154
178
  const summary = extractSummary(text);
155
179
  if (summary) sections.push(summary);
156
180
  if (details?.diff) {
157
- const rawDiff = details.diffLineNumbers ? toNumberedDiff(details.diff, details.diffLineNumbers) : details.diff;
158
- const diffLines = details.diff.split("\n");
181
+ const { header, body, lineNumbers } = splitBatchHeader(details.diff, details.diffLineNumbers);
182
+ const rawDiff = lineNumbers ? toNumberedDiff(body, lineNumbers) : body;
183
+ const diffLines = body.split("\n");
159
184
  const diffSection = expanded
160
185
  ? fmtResult(rawDiff, theme)
161
186
  : fmtPreview(rawDiff, false, theme);
@@ -163,7 +188,7 @@ export function buildAppliedText(
163
188
  !expanded && diffLines.length > RESULT_PREVIEW_LINES
164
189
  ? ` (${expandHint()})`
165
190
  : "";
166
- sections.push(`${diffSection}${hint}`);
191
+ sections.push(header !== undefined ? `${theme.fg("warning", header)}\n${diffSection}${hint}` : `${diffSection}${hint}`);
167
192
  }
168
193
  const warnings = details?.warnings?.length ? `Warnings:\n${details.warnings.join("\n")}` : extractWarnings(text);
169
194
  if (warnings) sections.push(warnings);
@@ -343,9 +368,17 @@ export function renderEditResult(
343
368
  renderState.preview = undefined;
344
369
  renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
345
370
  }
371
+ const batch = result.details?.batch;
372
+ if (batch && batch.last === false) {
373
+ const abortedMessage = typeof context?.toolCallId === "string" ? abortedBatchMessageFor(context.toolCallId) : undefined;
374
+ if (abortedMessage !== undefined) {
375
+ return reuseText(context, `\n${highlightBatchRefs(abortedMessage, theme)}`);
376
+ }
377
+ return reuseText(context, theme.fg("warning", `In batch ${batch.id}`));
378
+ }
346
379
  if (context.isError) {
347
380
  return renderedText
348
- ? reuseText(context, `\n${theme.fg("error", renderedText)}`)
381
+ ? reuseText(context, `\n${highlightBatchRefs(renderedText, theme)}`)
349
382
  : new Text("", 0, 0);
350
383
  }
351
384
  if (isApplied(result.details)) {
@@ -1,8 +1,7 @@
1
- import { formatSize, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
2
1
  import type { NEdit } from "./hashline";
3
2
  import { HASH_SEP } from "./hashline";
4
3
  import type { ReplaceDetails } from "./replace";
5
- import { genDiff, genPatch, type DiffSpan } from "./replace-diff";
4
+ import { genDiff, genPatch, fmtDedupRow, type DiffSpan } from "./replace-diff";
6
5
  import { visLines, clipLine } from "./utils";
7
6
  import { DEDUP_ANCHOR } from "./constants";
8
7
 
@@ -106,7 +105,7 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
106
105
  : "The edit produced identical content.";
107
106
  const dedupNote =
108
107
  boundaryRemovedLines !== undefined && boundaryRemovedLines > 0
109
- ? `\nBoundary dedup removed ${boundaryRemovedLines} line(s); resend the same edit to apply it literally.`
108
+ ? `\nBoundary dedup removed ${boundaryRemovedLines} line(s) that duplicated adjacent lines.`
110
109
  : "";
111
110
 
112
111
  const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${dedupNote}${warnBlock(warnings)}`;
@@ -131,13 +130,7 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
131
130
  },
132
131
  };
133
132
  }
134
- export function fmtDedupRow(line: string): string {
135
- const row = `${DEDUP_ANCHOR}${HASH_SEP}${line}`;
136
- if (Buffer.byteLength(row, "utf-8") <= DEFAULT_MAX_BYTES) return row;
137
- const size = formatSize(Buffer.byteLength(row, "utf-8"));
138
- const limit = formatSize(DEFAULT_MAX_BYTES);
139
- return `${DEDUP_ANCHOR}${HASH_SEP}[Row is ${size}, exceeds ${limit}; content not shown. Use read to see the full line.]`;
140
- }
133
+ export { fmtDedupRow };
141
134
 
142
135
  export function isDedupRow(line: string): boolean {
143
136
  return line.startsWith(`${DEDUP_ANCHOR}${HASH_SEP}`);
@@ -175,7 +168,9 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
175
168
  const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes, boundaryDedupAbove, boundaryDedupBelow, spans } = input;
176
169
  const resultLines = visLines(result);
177
170
  const baseDiff = genDiff(originalNormalized, result, diffContextLines, resultHashes, originalHashes, undefined, spans);
178
- const diffResult = withDedupRows(baseDiff.diff, baseDiff.lineNumbers, boundaryDedupAbove, boundaryDedupBelow);
171
+ const diffResult = baseDiff.spanDedupEmitted
172
+ ? baseDiff
173
+ : withDedupRows(baseDiff.diff, baseDiff.lineNumbers, boundaryDedupAbove, boundaryDedupBelow);
179
174
  const addedLines = editMeta.addedLines;
180
175
  const removedLines = editMeta.removedLines;
181
176
  const warningsBlock = warnBlock(warnings);
@@ -172,7 +172,7 @@ export function regUndo(pi: ExtensionAPI): void {
172
172
  content: [
173
173
  {
174
174
  type: "text",
175
- text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file was modified after the edit, so nothing was reverted. The current content already contains your applied edit plus that external change and is most likely the correct state. Do not modify the file to make an undo possible and do not revert your own edit. The undo record is kept. Call read() to verify the current state, then stop.`
175
+ text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file was modified after the edit, so nothing was reverted and the file was left untouched. The undo record is kept. Do not edit the file to force the undo. Call read() to inspect the current state.`
176
176
  },
177
177
  ],
178
178
  isError: true,
package/src/replace.ts CHANGED
@@ -32,10 +32,9 @@ import { loadHashStore, type HashStore } from "./hash-store";
32
32
  import { adoptAnchors, servedForPath, withAnchorSession } from "./anchor-registry";
33
33
  import { resolveTarget } from "./fs-write";
34
34
  import { toCwd } from "./paths";
35
- import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
36
35
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, getBoundaryDedupMode, withReplacePrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
37
- import { commitEdit } from "./commit";
38
- import { batchMemberFor, executeBatchMember, noteBatchFailure, suffixPoisonCause } from "./batch";
36
+ import { commitEdit, dedupRowsFromFixes } from "./commit";
37
+ import { batchMemberFor, executeBatchMember, noteBatchFailure } from "./batch";
39
38
 
40
39
  export { editToolSchema, type ReqParams, assertReq };
41
40
 
@@ -67,7 +66,6 @@ export interface PipelineResult {
67
66
  resultHashes: string[];
68
67
  totalAddedLines: number;
69
68
  totalRemovedLines: number;
70
- hadBoundaryDedup: boolean;
71
69
  boundaryRemovedLines: number;
72
70
  boundaryRemovedLineTexts: string[];
73
71
  boundaryDedupAbove: string[];
@@ -180,9 +178,7 @@ export async function execPipeline(
180
178
  edit, originalHashes, isNoop, anchorResult.autoFixes?.length ?? 0,
181
179
  );
182
180
 
183
- const sortedFixes = [...(anchorResult.autoFixes ?? [])].sort((a, b) => a.removedLineIndex - b.removedLineIndex);
184
- const aboveFixes = sortedFixes.filter((fix) => fix.kind === "leading" || fix.kind === "last-new-before");
185
- const belowFixes = sortedFixes.filter((fix) => fix.kind === "trailing" || fix.kind === "first-new-after");
181
+ const sortedFixes = dedupRowsFromFixes(anchorResult.autoFixes ?? []);
186
182
  const pipeSpan = isNoop ? undefined : hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
187
183
  const pipeResultCount = splitLines(result).length;
188
184
  const pipeSpans = pipeSpan ? [{ start: pipeSpan[0], end: pipeSpan[1], replacementCount: pipeResultCount - (originalHashes.length - (pipeSpan[1] - pipeSpan[0] + 1)) }] : undefined;
@@ -201,11 +197,10 @@ export async function execPipeline(
201
197
  originalHashes,
202
198
  totalAddedLines,
203
199
  totalRemovedLines,
204
- hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
205
200
  boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
206
- boundaryRemovedLineTexts: sortedFixes.map((fix) => fix.removedLine),
207
- boundaryDedupAbove: aboveFixes.map((fix) => fix.removedLine),
208
- boundaryDedupBelow: belowFixes.map((fix) => fix.removedLine),
201
+ boundaryRemovedLineTexts: sortedFixes.removedTexts,
202
+ boundaryDedupAbove: sortedFixes.above,
203
+ boundaryDedupBelow: sortedFixes.below,
209
204
  identity,
210
205
  ...(pipeSpans ? { spans: pipeSpans } : {}),
211
206
  };
@@ -287,54 +282,34 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
287
282
  }).catch((error: unknown) => {
288
283
  const member = batchMemberFor(_toolCallId);
289
284
  if (member) noteBatchFailure(member, error);
290
- else suffixPoisonCause(_toolCallId, error);
291
285
  throw error;
292
286
  });
293
287
  return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
294
288
  const dedupMode = await getBoundaryDedupMode();
295
- const dedupOn = dedupMode !== "off";
296
- const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
297
- const boundaryBypass = dedupOn ? consumeBoundaryBypass(mutationTargetPath, noopPayload) : false;
298
- const strictBoundaryDedup = dedupMode === "strict" && !boundaryBypass;
289
+ const strictBoundaryDedup = dedupMode === "strict";
299
290
  const member = batchMemberFor(_toolCallId);
300
291
  if (!member) {
301
- try {
302
- const pipe = await execPipeline(
303
- targetPath,
304
- normalizedParams,
305
- ctx.cwd,
306
- { accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
307
- );
308
- const appliedWarnings = boundaryBypass
309
- ? ["[W_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on."]
310
- : [];
311
- return await commitEdit(pipe, {
312
- path: pipe.path,
313
- absolutePath,
314
- mutationTargetPath,
315
- editAnchors: [normalizedParams.remove_from, normalizedParams.remove_to],
316
- signal,
317
- appliedWarnings,
318
- onApplied: () => { if (dedupOn) clearBoundaryBypass(mutationTargetPath); },
319
- onNoopDedup: dedupOn ? () => markBoundaryNoop(mutationTargetPath, noopPayload) : undefined,
320
- });
321
- } catch (error) {
322
- const detail = error instanceof Error ? error.message : String(error);
323
- if (boundaryBypass && !detail.includes("File was written;")) markBoundaryNoop(mutationTargetPath, noopPayload);
324
- throw error;
325
- }
292
+ const pipe = await execPipeline(
293
+ targetPath,
294
+ normalizedParams,
295
+ ctx.cwd,
296
+ { accessMode: constants.R_OK | constants.W_OK, signal },
297
+ );
298
+ return await commitEdit(pipe, {
299
+ path: pipe.path,
300
+ absolutePath,
301
+ mutationTargetPath,
302
+ editAnchors: [normalizedParams.remove_from, normalizedParams.remove_to],
303
+ signal,
304
+ });
326
305
  }
327
306
  let built: { edit: HEdit; warnings: string[] };
328
307
  try {
329
308
  built = buildReplaceHEdit(normalizedParams);
330
309
  } catch (error) {
331
310
  noteBatchFailure(member, error);
332
- if (boundaryBypass) markBoundaryNoop(mutationTargetPath, noopPayload);
333
311
  throw error;
334
312
  }
335
- const appliedWarnings = boundaryBypass
336
- ? ["[W_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on."]
337
- : [];
338
313
  return executeBatchMember({
339
314
  kind: "replace",
340
315
  member,
@@ -343,11 +318,9 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
343
318
  cwd: ctx.cwd,
344
319
  signal,
345
320
  hedit: built.edit,
346
- extraWarnings: [...built.warnings, ...appliedWarnings],
347
- skipBoundaryDedup: boundaryBypass,
321
+ extraWarnings: built.warnings,
322
+ skipBoundaryDedup: false,
348
323
  strictBoundaryDedup,
349
- noopPayload,
350
- bypassConsumed: boundaryBypass,
351
324
  });
352
325
  });
353
326
  });
@@ -1,51 +0,0 @@
1
- import { parseText } from "./hashline/parse";
2
- import { ANCHOR_ROW_RE } from "./hashline/resolve";
3
- import { stripRowPrefix } from "./hashline/hash";
4
-
5
- function canonRef(ref: string): string {
6
- const trimmed = ref.trim();
7
- const match = trimmed.match(ANCHOR_ROW_RE);
8
- return match ? match[2]! : trimmed;
9
- }
10
-
11
- function canonLines(lines: string[]): string[] {
12
- return parseText(lines).map((line) => stripRowPrefix(line).text);
13
- }
14
-
15
- const boundaryBypassTracker = new Map<string, string>();
16
- const BOUNDARY_BYPASS_LIMIT = 256;
17
-
18
- export function noopPayloadKey(
19
- absolutePath: string,
20
- removeFrom: string,
21
- removeTo: string,
22
- replacementLines: string[],
23
- ): string {
24
- return JSON.stringify([
25
- absolutePath,
26
- canonRef(removeFrom),
27
- canonRef(removeTo),
28
- canonLines(replacementLines),
29
- ]);
30
- }
31
-
32
- export function markBoundaryNoop(absolutePath: string, payload: string): void {
33
- boundaryBypassTracker.delete(absolutePath);
34
- boundaryBypassTracker.set(absolutePath, payload);
35
- if (boundaryBypassTracker.size > BOUNDARY_BYPASS_LIMIT) {
36
- const oldest = boundaryBypassTracker.keys().next().value;
37
- if (oldest !== undefined) boundaryBypassTracker.delete(oldest);
38
- }
39
- }
40
-
41
- export function consumeBoundaryBypass(absolutePath: string, payload: string): boolean {
42
- if (boundaryBypassTracker.get(absolutePath) === payload) {
43
- boundaryBypassTracker.delete(absolutePath);
44
- return true;
45
- }
46
- return false;
47
- }
48
-
49
- export function clearBoundaryBypass(absolutePath: string): void {
50
- boundaryBypassTracker.delete(absolutePath);
51
- }