pi-hashline-edit-pro 4.3.4 → 4.3.6

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
@@ -89,7 +89,7 @@ Single line: use the same anchor for `remove_from` and `remove_to`. `replace_fro
89
89
 
90
90
  The request is checked before any file I/O, so a bad request never touches the file.
91
91
 
92
- Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 characters before `│`, for example `ab12│`), diff-preview rows pasted into the replacement, and a boundary line pasted twice are reported as warnings, while a reversed range, a JSON-array wrapper, and embedded newlines are corrected silently. New lines that re-include a block adjacent to the range are stripped when that block is unique in the file. The whole run is stripped as one unit, so re-including an unchanged block next to the range never duplicates it. Boundary dedup has three modes in `/hashline-config`: `on` strips with a warning, `off` applies edits literally, and `strict` rejects the edit with `[E_BOUNDARY_STRICT]` when any replacement line would be stripped.
92
+ Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), diff-preview rows pasted into the replacement, and a boundary line pasted twice are reported as warnings, while a reversed range, a JSON-array wrapper, and embedded newlines are corrected silently. New lines that re-include a block adjacent to the range are stripped when that block is unique in the file. The whole run is stripped as one unit, so re-including an unchanged block next to the range never duplicates it. Boundary dedup has three modes in `/hashline-config`: `on` strips with a warning, `off` applies edits literally, and `strict` rejects the edit with `[E_BOUNDARY_STRICT]` when any replacement line would be stripped.
93
93
  Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to `replace`'s `replacement_lines` and `insert`'s `lines`.
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`.
@@ -176,7 +176,7 @@ All five tools return machine-readable metadata in `details` alongside the model
176
176
  | Tool | `details` |
177
177
  | --- | --- |
178
178
  | `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
179
- | `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+HASH│` and ` HASH│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
179
+ | `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+HASH│` and ` HASH│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership; earlier members also carry `aborted: true` and `abortMessage` after a batch abort), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
180
180
  | `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
181
181
  | `anchor_grep` | `metrics` with `matches` (capped at `limit`), `files`, and `truncated`; `truncation` (the standard pi truncation report) when output was cut; and `linesTruncated` (true when long lines were shown as fragments). |
182
182
 
@@ -233,7 +233,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
233
233
  | --- | --- |
234
234
  | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line), or content contains a NUL byte (`U+0000`), which would make the file binary. |
235
235
  | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example stringified array text that could not be parsed and was kept as one literal line). |
236
- | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-char anchor. |
236
+ | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
237
237
  | `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
238
238
  | `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
239
239
  | `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
@@ -249,7 +249,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
249
249
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
250
250
  | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
251
251
  | `[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. |
252
- | `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with an `anchor│` that was served for this file (any line position). The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
252
+ | `[E_WRITE_HASH_ECHO]` | A `write` `content` line reproduces a served row for this file (a bare `anchor│` read row, a `+anchor│`, ` anchor│`, or `-anchor│` diff row, or a `lineNumber │ anchor│content` grep row). The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
253
253
  | `[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. |
254
254
  | `[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. |
255
255
  | `[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. |
@@ -261,6 +261,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
261
261
  - Stale anchors. `[E_STALE_ANCHOR]` means an anchor is not owned in this session: it was never shown to you, or its line was edited or the file was rewritten since. Call `read` for fresh anchors and retry.
262
262
  - Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
263
263
  - Multi-conversation hosts. Anchors, served records, and ownership logs are resolved per calling session, so a tool call in one conversation is never answered by another conversation's registry; a foreign anchor fails with `[E_STALE_ANCHOR]`. Interactive previews are the one exception: pi does not pass the session into render callbacks, so when one process serves several conversations at once a preview can fall back to the most recently active session and show a stale or wrong-file diff. Previews never write files or claim anchors; run the call for the authoritative result.
264
+ - Undo scope. `undo_last_change` records are keyed by file path, not by session, so in a multi-conversation host any conversation that names the file can revert its most recent `replace` or `insert`, even one made by another conversation. Anchor ownership remains session-scoped; only undo is shared.
264
265
  - Reset the anchor state. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars) and in per-session ownership logs under `~/.config/pi-hashline-edit-pro/sessions/`. Quit pi, delete those files, and everything is rebuilt on the next session. Anchor history is lost, but no project files are touched.
265
266
  - Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
266
267
  - Config directory moved. If `XDG_CONFIG_HOME` is set on a non-Windows platform, the config directory (and the anchor state inside it) lives at `$XDG_CONFIG_HOME/pi-hashline-edit-pro` instead of `~/.config/pi-hashline-edit-pro`. An existing store is not migrated automatically. To keep anchor and undo history, move the old `hash-store.sqlite` files (plus `-wal`/`-shm` sidecars) into the new directory before the first run.
package/index.ts CHANGED
@@ -7,6 +7,7 @@ import { regGrep } from "./src/grep";
7
7
  import { regUndo, clearUndo } from "./src/replace-undo";
8
8
  import { regRead, fmtReadPreview } from "./src/read";
9
9
  import { buildAutoReadAllInjection, autoReadAllBudget } from "./src/auto-read-all";
10
+ import { clearAutoReadAllComplete } from "./src/auto-read-all-state";
10
11
  import type { RMetrics } from "./src/replace-response";
11
12
  import type { ReplaceDetails } from "./src/replace";
12
13
  import { extractWarnings } from "./src/replace-render";
@@ -103,6 +104,7 @@ export default function (pi: ExtensionAPI): void {
103
104
  pi.on("session_shutdown", async (_event, ctx) => {
104
105
  try {
105
106
  const key = sessionKeyFor(ctx);
107
+ clearAutoReadAllComplete(key);
106
108
  if (key !== undefined) releaseRegistrySession(key);
107
109
  } catch (error) {
108
110
  console.error("Failed to release anchor registry session:", error);
@@ -113,7 +115,7 @@ export default function (pi: ExtensionAPI): void {
113
115
  if (autoReadAll === "off" || autoReadAllInjected) return;
114
116
  autoReadAllInjected = true;
115
117
  try {
116
- const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore);
118
+ const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore, sessionKeyFor(ctx));
117
119
  if (!injection) return;
118
120
  if (ctx.hasUI) ctx.ui.notify(`Auto-read all: attached ${injection.files} file(s) with anchors`, "info");
119
121
  return { message: { customType: AUTO_READ_ALL_CUSTOM_TYPE, content: injection.text, display: false } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.3.4",
3
+ "version": "4.3.6",
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",
@@ -58,10 +58,10 @@
58
58
  "test:coverage": "vitest run --coverage --coverage.thresholds.lines=90 --coverage.thresholds.statements=90 --coverage.thresholds.functions=85 --coverage.thresholds.branches=80",
59
59
  "lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.ts\"",
60
60
  "typecheck": "tsc --noEmit",
61
- "prepublishOnly": "npm run typecheck && npm run lint && npm test"
61
+ "prepublishOnly": "npm run typecheck && npm run lint"
62
62
  },
63
63
  "devDependencies": {
64
- "@earendil-works/pi-coding-agent": "^0.84.0",
64
+ "@earendil-works/pi-coding-agent": "^0.86.1",
65
65
  "@eslint/js": "^10.0.1",
66
66
  "@types/node": "^24",
67
67
  "@vitest/coverage-v8": "^4.1.11",
package/prompts/read.md CHANGED
@@ -1 +1 @@
1
- Read a text file and return it as `anchor│content` rows, one per line: a 4-character alphanumeric anchor, the `│` separator, then the line content. Target lines in replace and insert by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
1
+ Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only), the `│` separator, then the line content. Target lines in replace and insert by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
@@ -659,6 +659,7 @@ export function alignOwnershipWithSpans(
659
659
  const state = options?.shadow ? shadowStateFrom(current()!) : current()!;
660
660
  const freed: { anchor: string; checksum: string }[] = [];
661
661
  const minted: MintedAt[] = [];
662
+ const reused = new Set<string>();
662
663
  const log = (event: RegistryEvent): void => {
663
664
  if (!options?.shadow) appendEvent(event);
664
665
  };
@@ -671,14 +672,12 @@ export function alignOwnershipWithSpans(
671
672
  const start = span.start + offset;
672
673
  const end = span.end + offset;
673
674
  const spanLength = end - start + 1;
674
- const spanFreed: { anchor: string; checksum: string }[] = [];
675
675
  for (let i = start; i <= end; i++) {
676
676
  const anchor = anchors[i]!;
677
677
  if (state.owned.has(anchor)) {
678
678
  state.owned.delete(anchor);
679
679
  const checksum = prevChecksums[i - offset] ?? "";
680
- spanFreed.push({ anchor, checksum });
681
- freed.push(spanFreed[spanFreed.length - 1]!);
680
+ freed.push({ anchor, checksum });
682
681
  }
683
682
  }
684
683
  const replacement: (string | undefined)[] = new Array(span.replacementCount);
@@ -690,10 +689,7 @@ export function alignOwnershipWithSpans(
690
689
  (!state.owned.has(positional) || state.owned.get(positional)!.path === path)
691
690
  ) {
692
691
  replacement[k] = positional;
693
- const freedAt = freed.findIndex((candidate) => candidate.anchor === positional);
694
- if (freedAt >= 0) freed.splice(freedAt, 1);
695
- const pooledAt = spanFreed.findIndex((candidate) => candidate.anchor === positional);
696
- if (pooledAt >= 0) spanFreed.splice(pooledAt, 1);
692
+ reused.add(positional);
697
693
  }
698
694
  }
699
695
  for (let k = 0; k < span.replacementCount; k++) {
@@ -714,8 +710,9 @@ export function alignOwnershipWithSpans(
714
710
  }
715
711
  const rows: [string, string][] = minted.map((m) => [m.anchor, m.checksum]);
716
712
  if (rows.length > 0) log({ kind: "allocate", path, rows });
717
- if (freed.length > 0) log({ kind: "free", path, anchors: freed.map((f) => f.anchor) });
718
- return { anchors, freed: freed.map((f) => f.anchor), minted: minted.map((m) => m.anchor) };
713
+ const retained = freed.filter((candidate) => !reused.has(candidate.anchor));
714
+ if (retained.length > 0) log({ kind: "free", path, anchors: retained.map((f) => f.anchor) });
715
+ return { anchors, freed: retained.map((f) => f.anchor), minted: minted.map((m) => m.anchor) };
719
716
  }
720
717
 
721
718
  export function alignOwnership(
@@ -1,13 +1,27 @@
1
- const completeSnapshots = new Map<string, string>();
2
- export function recordAutoReadAllComplete(absolutePath: string, snapshotId: string): void {
3
- completeSnapshots.set(absolutePath, snapshotId);
1
+ const completeSnapshots = new Map<string, Map<string, string>>();
2
+
3
+ function scopeFor(sessionKey: string | undefined): string {
4
+ return sessionKey ?? "";
4
5
  }
5
- export function getAutoReadAllSnapshot(absolutePath: string): string | undefined {
6
- return completeSnapshots.get(absolutePath);
6
+
7
+ export function recordAutoReadAllComplete(sessionKey: string | undefined, absolutePath: string, snapshotId: string): void {
8
+ const scope = scopeFor(sessionKey);
9
+ let snapshots = completeSnapshots.get(scope);
10
+ if (!snapshots) {
11
+ snapshots = new Map();
12
+ completeSnapshots.set(scope, snapshots);
13
+ }
14
+ snapshots.set(absolutePath, snapshotId);
7
15
  }
8
- export function invalidateAutoReadAllComplete(absolutePath: string): void {
9
- completeSnapshots.delete(absolutePath);
16
+
17
+ export function getAutoReadAllSnapshot(sessionKey: string | undefined, absolutePath: string): string | undefined {
18
+ return completeSnapshots.get(scopeFor(sessionKey))?.get(absolutePath);
10
19
  }
11
- export function clearAutoReadAllComplete(): void {
20
+
21
+ export function clearAutoReadAllComplete(sessionKey: string | undefined): void {
22
+ completeSnapshots.delete(scopeFor(sessionKey));
23
+ }
24
+
25
+ export function clearAllAutoReadAllComplete(): void {
12
26
  completeSnapshots.clear();
13
27
  }
@@ -388,6 +388,16 @@ export function chunkAutoReadAllSections(sections: string[], maxBytes: number =
388
388
  if (current.length > 0) chunks.push(current.join("\n\n"));
389
389
  return chunks;
390
390
  }
391
+
392
+ async function candidateFileBytes(cwd: string, file: string): Promise<number | undefined> {
393
+ try {
394
+ const stats = await lstat(resolve(cwd, file));
395
+ return stats.isFile() ? stats.size : undefined;
396
+ } catch {
397
+ return undefined;
398
+ }
399
+ }
400
+
391
401
  async function renderFile(file: string, cwd: string): Promise<AutoReadAllSection | undefined> {
392
402
  try {
393
403
  const { normalized, fileHashes, absolutePath } = await readNormFile(file, cwd, { maxLines: MAX_HASH_LINES });
@@ -419,7 +429,7 @@ function buildFooter(attached: number, discovery: AutoReadAllDiscovery, omitted:
419
429
  return `[hashline auto-read-all: ${attached} file(s) attached from ${discovery.source}; ${summary}${omissionNote}]`;
420
430
  }
421
431
 
422
- export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "on", ignoreDirs: readonly string[] = []): Promise<AutoReadAllInjection | undefined> {
432
+ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "on", ignoreDirs: readonly string[] = [], sessionKey?: string): Promise<AutoReadAllInjection | undefined> {
423
433
  const discovery = await discoverAutoReadAllFiles(cwd, mode, ignoreDirs);
424
434
  if (discovery.files.length === 0) return undefined;
425
435
  const sections: AutoReadAllSection[] = [];
@@ -427,6 +437,13 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
427
437
  let bytes = 0;
428
438
  let completeFiles = 0;
429
439
  for (const file of discovery.files) {
440
+ if (sections.length > 0) {
441
+ const size = await candidateFileBytes(cwd, file);
442
+ if (size !== undefined && bytes + size > budgetBytes) {
443
+ omitted.push(file);
444
+ continue;
445
+ }
446
+ }
430
447
  const section = await renderFile(file, cwd);
431
448
  if (section === undefined) {
432
449
  omitted.push(file);
@@ -439,7 +456,7 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
439
456
  }
440
457
  sections.push(section);
441
458
  const snapshotId = await safeSnapId(section.absolutePath, "auto-read-all");
442
- if (snapshotId !== undefined) recordAutoReadAllComplete(section.absolutePath, snapshotId);
459
+ if (snapshotId !== undefined) recordAutoReadAllComplete(sessionKey, section.absolutePath, snapshotId);
443
460
  bytes += sectionBytes;
444
461
  completeFiles += 1;
445
462
  }
package/src/batch.ts CHANGED
@@ -23,11 +23,11 @@ import { assertInsertReq, assertReq, normReq } from "./payload-contract";
23
23
  import { saveUndo } from "./replace-undo";
24
24
  import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
25
25
  import { serveRows, servedHashesFromDiff } from "./served";
26
- import { abortIf, assertLineLimit, errCode, isRec, splitLines } from "./utils";
27
- import { MAX_BYTES } from "./constants";
26
+ import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, splitLines } from "./utils";
28
27
 
29
28
  export interface PlannedMember {
30
29
  batchKey: number;
30
+ id: string;
31
31
  display: number;
32
32
  total: number;
33
33
  target: string;
@@ -126,12 +126,18 @@ const batches = new Map<number, BatchState>();
126
126
  let nextBatchKey = 1;
127
127
  const abortedMembers = new Map<string, { display: number; message: string }>();
128
128
  const ABORTED_MEMBERS_LIMIT = 1024;
129
+ const placeholderResults = new Map<string, TResult>();
129
130
 
130
131
  function markBatchMembersAborted(runtime: BatchState): void {
131
132
  const message = abortedBatchMessage(runtime);
132
133
  for (const id of runtime.memberIds) {
133
134
  abortedMembers.delete(id);
134
135
  abortedMembers.set(id, { display: runtime.display, message });
136
+ const result = placeholderResults.get(id);
137
+ if (result?.details.batch) {
138
+ result.details.batch.aborted = true;
139
+ result.details.batch.abortMessage = message;
140
+ }
135
141
  }
136
142
  while (abortedMembers.size > ABORTED_MEMBERS_LIMIT) {
137
143
  const oldest = abortedMembers.keys().next().value;
@@ -157,6 +163,7 @@ export function resetBatchStateForTests(): void {
157
163
  plan.clear();
158
164
  batches.clear();
159
165
  abortedMembers.clear();
166
+ placeholderResults.clear();
160
167
  nextBatchKey = 1;
161
168
  }
162
169
 
@@ -203,7 +210,12 @@ function enforceCap(): void {
203
210
  if (oldest === undefined) return;
204
211
  const state = batches.get(oldest);
205
212
  batches.delete(oldest);
206
- if (state) for (const id of state.memberIds) plan.delete(id);
213
+ if (state) {
214
+ for (const id of state.memberIds) {
215
+ plan.delete(id);
216
+ placeholderResults.delete(id);
217
+ }
218
+ }
207
219
  }
208
220
  }
209
221
 
@@ -274,6 +286,7 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
274
286
  group.forEach((item, index) => {
275
287
  plan.set(item.id, {
276
288
  batchKey: key,
289
+ id: item.id,
277
290
  display,
278
291
  total: finalGroups.length,
279
292
  target: item.target,
@@ -521,7 +534,11 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
521
534
  if (noop) runtime.noops += 1;
522
535
  else runtime.applied += 1;
523
536
  runtime.warnings.push(...piece.warnings);
524
- if (!input.member.last) return batchPlaceholder(input.member, piece, base.snapshotId);
537
+ if (!input.member.last) {
538
+ const placeholder = batchPlaceholder(input.member, piece, base.snapshotId);
539
+ placeholderResults.set(input.member.id, placeholder);
540
+ return placeholder;
541
+ }
525
542
  return finishBatch(input.member, input.signal);
526
543
  }
527
544
 
@@ -633,9 +650,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
633
650
  assertNotEmpty(base.content, composed);
634
651
  assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
635
652
  const finalBytes = base.bom + restoreEndings(composed, base.ending);
636
- if (Buffer.byteLength(finalBytes, "utf-8") > MAX_BYTES) {
637
- 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.`);
638
- }
653
+ assertByteLimit(finalBytes, paths.displayPath);
639
654
  } catch (error) {
640
655
  discardBatchState(runtime);
641
656
  if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
@@ -739,7 +754,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
739
754
  await getDiffContextLines(),
740
755
  );
741
756
  changed.details.diff = `${header}\n${changed.details.diff}`;
742
- changed.details.diffLineNumbers?.unshift(undefined);
757
+ changed.details.diffLineNumbers?.unshift(null);
743
758
  try {
744
759
  serveRows(runtime.target, resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff));
745
760
  } catch (error) {
@@ -786,7 +801,10 @@ export async function finalizeTurn(toolCallIds: string[]): Promise<void> {
786
801
  for (const key of keys) {
787
802
  const runtime = batches.get(key);
788
803
  if (!runtime) continue;
789
- for (const id of runtime.memberIds) plan.delete(id);
804
+ for (const id of runtime.memberIds) {
805
+ plan.delete(id);
806
+ placeholderResults.delete(id);
807
+ }
790
808
  batches.delete(key);
791
809
  }
792
810
  }
package/src/commit.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import type { PipelineResult } from "./replace";
3
- import { abortIf, errCode, splitLines } from "./utils";
3
+ import { abortIf, assertByteLimit, errCode, splitLines } from "./utils";
4
4
  import { DEDUP_ANCHOR } from "./constants";
5
5
  import { HASH_SEP } from "./hashline";
6
6
  import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
@@ -70,6 +70,9 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
70
70
  );
71
71
  }
72
72
 
73
+ const finalFileBytes = pipe.bom + restoreEndings(pipe.result, pipe.originalEnding);
74
+ assertByteLimit(finalFileBytes, path);
75
+
73
76
  if (pipe.hadUtf8DecodeErrors) {
74
77
  warnings.push(
75
78
  "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
@@ -112,7 +115,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
112
115
  abortIf(signal);
113
116
  await writeAtomic(
114
117
  absolutePath,
115
- pipe.bom + restoreEndings(pipe.result, pipe.originalEnding),
118
+ finalFileBytes,
116
119
  pipe.identity,
117
120
  );
118
121
  } catch (error) {
package/src/grep.ts CHANGED
@@ -3,11 +3,12 @@ import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult
3
3
  import { Type } from "typebox";
4
4
  import { stat } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, join, win32 } from "node:path";
6
+ import { pathToFileURL } from "node:url";
6
7
  import { spawn, spawnSync } from "node:child_process";
7
8
  import { createInterface } from "node:readline";
8
9
  import { tryReadNormFile } from "./file-reader";
9
10
  import { globToRegex } from "./glob";
10
- import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP } from "./hashline";
11
+ import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP, HASH_CLASS } from "./hashline";
11
12
  import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
12
13
  import { toCwd, toDisplayPath } from "./paths";
13
14
  import { loadP, loadGuide } from "./prompts";
@@ -276,10 +277,9 @@ export async function resolveRgPath(): Promise<string> {
276
277
  const { createRequire } = await import("node:module");
277
278
  const require = createRequire(import.meta.url);
278
279
  const pkgPath = require.resolve("@earendil-works/pi-coding-agent/package.json");
279
- const { dirname } = await import("node:path");
280
280
  const piDir = dirname(pkgPath);
281
281
  const toolsManagerPath = join(piDir, "dist/utils/tools-manager.js");
282
- const mod = await import("file://" + toolsManagerPath);
282
+ const mod = await import(pathToFileURL(toolsManagerPath).href);
283
283
  if (mod.ensureTool) {
284
284
  const p = await mod.ensureTool("rg", true);
285
285
  if (p) { cachedRgPath = p; return p; }
@@ -468,7 +468,7 @@ function highlightMatches(text: string, regex: RegExp, theme: FgT): string {
468
468
  return out + text.slice(last);
469
469
  }
470
470
 
471
- const ANCHORED_ROW_RE = /^[A-Za-z0-9]{4}│/;
471
+ const ANCHORED_ROW_RE = new RegExp(`^${HASH_CLASS}${HASH_SEP}`);
472
472
 
473
473
  function highlightHitRow(row: string, highlight: RegExp, theme: FgT): string {
474
474
  const anchored = row.match(ANCHORED_ROW_RE);
package/src/hash-store.ts CHANGED
@@ -117,7 +117,6 @@ interface Prepared {
117
117
  get: (...params: SqlParams) => Record<string, unknown> | undefined;
118
118
  getState: (...params: SqlParams) => Record<string, unknown> | undefined;
119
119
  allPaths: (...params: SqlParams) => Record<string, unknown>[];
120
- allHashes: (...params: SqlParams) => Record<string, unknown>[];
121
120
  deleteOne: (...params: SqlParams) => void;
122
121
  upsert: (...params: SqlParams) => void;
123
122
  undoUpsert: (...params: SqlParams) => void;
@@ -205,7 +204,6 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
205
204
  const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
206
205
  const getStateStmt = db.prepare("SELECT checksum, hashes, line_checksums FROM snapshots WHERE path = ?");
207
206
  const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
208
- const allHashesStmt = db.prepare("SELECT path, hashes FROM snapshots");
209
207
  const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
210
208
  const upsertStmt = db.prepare(
211
209
  "INSERT INTO snapshots (path, checksum, line_count, hashes, line_checksums, updated_at) VALUES (?, ?, ?, ?, ?, ?) " +
@@ -224,8 +222,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
224
222
  const stmts: Prepared = {
225
223
  get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
226
224
  getState: (...params) => getStateStmt.get(...params) as Record<string, unknown> | undefined,
227
- allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
228
- allHashes: (...params) => allHashesStmt.all(...params) as Record<string, unknown>[],
225
+ allPaths: (...params) => withBusyRetry(() => allStmt.all(...params) as Record<string, unknown>[]),
229
226
  deleteOne: retriedWrite(delStmt),
230
227
  upsert: retriedWrite(upsertStmt),
231
228
  undoUpsert: retriedWrite(undoUpsertStmt),
@@ -6,32 +6,16 @@ if (typeof rawAnchors !== "string" || rawAnchors.length === 0 || rawAnchors.leng
6
6
  const TABLE: string = rawAnchors;
7
7
  export const HASH_LEN = 4;
8
8
  export const ANCHOR_COUNT = TABLE.length / HASH_LEN;
9
- const ALNUM = "A-Za-z0-9";
9
+ const LETTERS = "A-Za-z";
10
10
 
11
- export const ALPH_RE = new RegExp(`^[${ALNUM}]+$`);
11
+ export const ALPH_RE = new RegExp(`^[${LETTERS}]+$`);
12
12
 
13
- export const HASH_CLASS = `[${ALNUM}]{${HASH_LEN}}`;
13
+ export const HASH_CLASS = `[${LETTERS}]{${HASH_LEN}}`;
14
14
 
15
- export const HASH_RUN = `[${ALNUM}]{${HASH_LEN},${HASH_LEN + 1}}`;
15
+ export const HASH_RUN = `[${LETTERS}]{${HASH_LEN},${HASH_LEN + 1}}`;
16
16
 
17
17
  export const HASH_RE = new RegExp(`^${HASH_CLASS}$`);
18
18
 
19
- let indexByAnchor: Map<string, number> | undefined;
20
-
21
- function reverseIndex(): Map<string, number> {
22
- if (!indexByAnchor) {
23
- indexByAnchor = new Map();
24
- for (let i = 0; i < ANCHOR_COUNT; i++) {
25
- indexByAnchor.set(TABLE.slice(i * HASH_LEN, i * HASH_LEN + HASH_LEN), i);
26
- }
27
- }
28
- return indexByAnchor;
29
- }
30
-
31
19
  export function anchorAt(idx: number): string {
32
20
  return TABLE.slice(idx * HASH_LEN, idx * HASH_LEN + HASH_LEN);
33
21
  }
34
-
35
- export function anchorIndex(hash: string): number {
36
- return reverseIndex().get(hash) ?? -1;
37
- }
@@ -1,5 +1,4 @@
1
1
  import { abortIf, clipLine, splitLines } from "../utils";
2
- import { _lineHashesPure } from "./hash";
3
2
  import {
4
3
  valEdit,
5
4
  stripBarePrefixes,
@@ -13,7 +13,7 @@ function diagRef(ref: string): string {
13
13
  const trimmed = ref.trim();
14
14
 
15
15
  if (!trimmed.length) {
16
- return `[E_BAD_REF] Invalid anchor. Expected a 4-char alphanumeric anchor (e.g. "Hasu").`;
16
+ return `[E_BAD_REF] Invalid anchor. Expected a 4-character anchor (letters only, e.g. "Hasu").`;
17
17
  }
18
18
 
19
19
  if (/^\d+/.test(trimmed)) {
@@ -29,13 +29,13 @@ function diagRef(ref: string): string {
29
29
  const firstHash = firstMatch?.[0] ?? "Hasu";
30
30
  const lastHash = lastMatch?.[0] ?? "Hasu";
31
31
  const preview = first.slice(0, 60);
32
- return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 4-char hash (e.g. "Hasu"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in replacement_lines.`;
32
+ return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 4-character anchor (letters only, e.g. "Hasu"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in replacement_lines.`;
33
33
  }
34
34
  if (trimmed.includes("│")) {
35
- return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 4-char anchor, drop everything from "│" onward.`;
35
+ return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 4-character anchor, drop everything from "│" onward.`;
36
36
  }
37
37
 
38
- return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 4-char alphanumeric anchor (e.g. "Hasu").`;
38
+ return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 4-character anchor (letters only, e.g. "Hasu").`;
39
39
  }
40
40
 
41
41
  function parseRef(ref: string): Anchor {
package/src/read.ts CHANGED
@@ -19,7 +19,7 @@ import { withReadPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-
19
19
  import { valAccess } from "./validation";
20
20
  import { readConfig } from "./config";
21
21
  import { resolveTarget } from "./fs-write";
22
- import { withAnchorSession, servedForPath } from "./anchor-registry";
22
+ import { withAnchorSession, servedForPath, sessionKeyFor } from "./anchor-registry";
23
23
  import { serveRows } from "./served";
24
24
  import { getAutoReadAllSnapshot } from "./auto-read-all-state";
25
25
  import { Text } from "@earendil-works/pi-tui";
@@ -217,7 +217,7 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
217
217
  if (autoReadAllMode !== "off") {
218
218
  const canonical = await resolveTarget(absolutePath).catch(() => undefined);
219
219
  if (canonical !== undefined) {
220
- const stored = getAutoReadAllSnapshot(canonical);
220
+ const stored = getAutoReadAllSnapshot(sessionKeyFor(ctx), canonical);
221
221
  if (stored !== undefined) {
222
222
  const current = await safeSnapId(canonical, "auto-read-all guard");
223
223
  if (current !== undefined && current === stored) {
@@ -4,6 +4,7 @@ import {
4
4
  _lineHashesPure,
5
5
  ANCHOR_LEN,
6
6
  HASH_SEP,
7
+ HASH_CLASS,
7
8
  changedRange,
8
9
  } from "./hashline";
9
10
  import { MAX_DIFF_INPUT_BYTES, DEDUP_ANCHOR } from "./constants";
@@ -111,7 +112,7 @@ export function spansFromHashes(oldHashes: string[], newHashes: string[]): DiffS
111
112
  return spans;
112
113
  }
113
114
 
114
- const ANCHORED_DIFF_ROW_RE = /^([+ -])([A-Za-z0-9]{4})│/;
115
+ const ANCHORED_DIFF_ROW_RE = new RegExp(`^([+ -])(${HASH_CLASS})│`);
115
116
 
116
117
  export function disambiguateDuplicateAnchors(diff: string): string {
117
118
  if (!diff.includes("│")) return diff;
@@ -42,7 +42,7 @@ export function colorLines(lines: string[], theme: FgT): string[] {
42
42
  return theme.fg("dim", line);
43
43
  });
44
44
  }
45
- export function toNumberedDiff(diff: string, lineNumbers: (number|undefined)[]): string {
45
+ export function toNumberedDiff(diff: string, lineNumbers: (number | null | undefined)[]): string {
46
46
  return withLineNumbers(diff, lineNumbers);
47
47
  }
48
48
 
@@ -162,7 +162,7 @@ function extractSummary(text: string | undefined): string | undefined {
162
162
 
163
163
  const BATCH_HEADER_RE = /^batch \d+:\n/;
164
164
 
165
- function splitBatchHeader(diff: string, lineNumbers: (number | undefined)[] | undefined): { header: string | undefined; body: string; lineNumbers: (number | undefined)[] | undefined } {
165
+ function splitBatchHeader(diff: string, lineNumbers: (number | null | undefined)[] | undefined): { header: string | undefined; body: string; lineNumbers: (number | null | undefined)[] | undefined } {
166
166
  const match = diff.match(BATCH_HEADER_RE);
167
167
  if (!match) return { header: undefined, body: diff, lineNumbers };
168
168
  return { header: match[0]!.slice(0, -1), body: diff.slice(match[0].length), lineNumbers: lineNumbers?.slice(1) };
@@ -240,7 +240,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
240
240
  editMeta.firstChangedLine ?? baseDiff.firstChangedLine,
241
241
  snapshotId,
242
242
  metrics,
243
- diffLineNumbers: diffResult.lineNumbers,
243
+ diffLineNumbers: diffResult.lineNumbers.map((line) => line ?? null),
244
244
  ...(warnings?.length ? { warnings: [...warnings] } : {}),
245
245
  },
246
246
  };
@@ -248,7 +248,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
248
248
  ],
249
249
  details: {
250
250
  diff: undoDiff,
251
- diffLineNumbers: undoDiffResult.lineNumbers,
251
+ diffLineNumbers: undoDiffResult.lineNumbers.map((line) => line ?? null),
252
252
  patch: patchResult.patch,
253
253
  ...(patchResult.truncated ? { patchTruncated: true as const } : {}),
254
254
  metrics: buildMetrics({
package/src/replace.ts CHANGED
@@ -45,9 +45,9 @@ export type ReplaceDetails = {
45
45
  snapshotId?: string;
46
46
  classification?: "noop";
47
47
  metrics?: RMetrics;
48
- diffLineNumbers?: (number|undefined)[];
48
+ diffLineNumbers?: (number | null)[];
49
49
  warnings?: string[];
50
- batch?: { id: number; size: number; last: boolean; total: number };
50
+ batch?: { id: number; size: number; last: boolean; total: number; aborted?: boolean; abortMessage?: string };
51
51
  };
52
52
 
53
53
  export interface PipelineResult {
package/src/utils.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { NUL_CONTENT_MSG } from "./constants";
1
+ import { NUL_CONTENT_MSG, MAX_BYTES } from "./constants";
2
+ import { HASH_CLASS } from "./hashline/alphabet";
2
3
 
3
4
  export function isRec(value: unknown): value is Record<string, unknown> {
4
5
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -141,8 +142,10 @@ export function getCached<K, V>(map: Map<K, V>, key: K, compute: (key: K) => V):
141
142
  return v;
142
143
  }
143
144
 
145
+ const HASH_ROW_RE = new RegExp(`^${HASH_CLASS}│`);
146
+
144
147
  export function isHashRow(line: string): boolean {
145
- return /^[A-Za-z0-9]{4}│/.test(line);
148
+ return HASH_ROW_RE.test(line);
146
149
  }
147
150
 
148
151
  export function gutterWidth(max: number, fallback: number): number {
@@ -170,14 +173,14 @@ export function numberedRead(text: string, offset: number): string {
170
173
  }).join("\n");
171
174
  }
172
175
 
173
- export function withLineNumbers(text: string, numbers: (number|undefined)[]): string {
176
+ export function withLineNumbers(text: string, numbers: (number | null | undefined)[]): string {
174
177
  const lines = text.split("\n");
175
178
  const nums = numbers ?? [];
176
- const max = nums.reduce<number>((m, n) => n !== undefined && n > m ? n : m, 0);
179
+ const max = nums.reduce<number>((m, n) => n !== undefined && n !== null && n > m ? n : m, 0);
177
180
  const width = gutterWidth(max, lines.length);
178
181
  return lines.map((line, i) => {
179
182
  const n = nums[i];
180
- const prefix = n !== undefined ? formatGutter(n, width) : blankGutter(width);
183
+ const prefix = n !== undefined && n !== null ? formatGutter(n, width) : blankGutter(width);
181
184
  return prefix + line;
182
185
  }).join("\n");
183
186
  }
@@ -189,6 +192,11 @@ export function assertLineLimit(content: string, displayPath: string, limit: num
189
192
  const count = splitLines(content).length;
190
193
  if (count > limit) throw new Error(formatLineLimit(displayPath, limit, count));
191
194
  }
195
+ export function assertByteLimit(content: string, displayPath: string, limit = MAX_BYTES): void {
196
+ if (Buffer.byteLength(content, "utf-8") > limit) {
197
+ throw new Error(`[E_FILE_TOO_LARGE] File is too large: ${displayPath} (exceeds the ${limit / (1024 * 1024)}MB size limit). For very large files, use write.`);
198
+ }
199
+ }
192
200
  export function lineLimitMoreThanMessage(displayPath: string, limit: number): string {
193
201
  return formatLineLimit(displayPath, limit, undefined);
194
202
  }
package/src/write-hook.ts CHANGED
@@ -5,7 +5,7 @@ import { servedForPath, withAnchorSession } from "./anchor-registry";
5
5
  import { resolveInCwd } from "./fs-write";
6
6
  import { abortIf, splitLines, isRec, normalizeFilePath } from "./utils";
7
7
 
8
- const HASH_ECHO_RE = new RegExp(`^(${HASH_CLASS})${HASH_SEP}`);
8
+ const HASH_ECHO_RE = new RegExp(`^(?: *[0-9]+ ${HASH_SEP} )?[+ -]?(${HASH_CLASS})${HASH_SEP}`);
9
9
 
10
10
  function searchEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
11
11
  for (let i = 0; i < lines.length; i++) {
@@ -27,7 +27,7 @@ export async function servedHashEchoDenial(rawPath: string, content: string, cwd
27
27
  if (!served || served.size === 0) return undefined;
28
28
  const echo = findServedHashEcho(content, served);
29
29
  if (!echo) return undefined;
30
- return `[E_WRITE_HASH_ECHO] Refused write to ${rawPath}: line ${echo.line} begins with the exact ${echo.hash}${HASH_SEP} anchor served for this file. Remove the copied anchors and retry.`;
30
+ return `[E_WRITE_HASH_ECHO] Refused write to ${rawPath}: line ${echo.line} contains the copied ${echo.hash}${HASH_SEP} anchor served for this file. Remove the copied anchors and retry.`;
31
31
  }
32
32
 
33
33
  export function registerWriteHook(pi: ExtensionAPI): void {