pi-hashline-edit-pro 4.2.0 → 4.2.2

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