pi-hashline-edit-pro 6.4.4 → 6.4.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
@@ -341,7 +341,7 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
341
341
 
342
342
  Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing; `outline` attaches an anchor-stamped structural outline per file; and `full` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk) and attaches full contents. `Git repos only` is on by default and keeps the whole injection inside a git repository; turn it off to attach files in any directory. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
343
343
 
344
- Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive; `resources/views/vendor` is kept so Laravel view overrides stay attached), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, _ide_helper.php, _ide_helper_models.php, .phpstorm.meta.php, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file; `full` also caps the budget at 2MB, while `outline` follows the context-derived budget because outlines stay compact. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
344
+ Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive; `resources/views/vendor` is kept so Laravel view overrides stay attached), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, _ide_helper.php, _ide_helper_models.php, .phpstorm.meta.php, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file; `full` also caps the budget at 2MB, while `outline` follows the context-derived budget because outlines stay compact. Files are ranked before the budget applies: working-tree changes first, then manifest and entry files, then the dominant source tree, then everything else, and no single top-level directory may take more than 40% of the budget in the first pass, so one large subtree cannot crowd out the rest. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
345
345
 
346
346
  Each attached file starts with a header (`=== path ===` in `full` mode, `=== path (language) — lines ===` in `outline` mode) followed by its anchor rows. Edit directly from the attachment with replace and insert, so no `read` is needed. `full` attaches files whole; `outline` attaches each file's structural outline within the budget. In outline mode, multi-line symbols appear as labels with `[limit N]` and single-line symbols appear as their content.
347
347
 
@@ -482,12 +482,12 @@ Full reference:
482
482
  | `[E_UNDO_NONE]` | `undo_last_change` found no recorded edit for the file. Nothing was changed; make an edit first. |
483
483
  | `[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; a pure deletion checks only its first and last line). The edit was refused; the current range is returned with fresh anchors. |
484
484
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
485
- | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
485
+ | `[E_REGISTRY]` | The anchor registry was not initialized, or the session's registry was released because the session was replaced or reloaded; a serve or edit ran without a usable session. Retry the operation in the current session. |
486
486
  | `[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. |
487
487
  | `[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). |
488
488
  | `[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. |
489
489
  | `[E_BATCH_OVERLAP]` | Batched edit 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. |
490
- | `[E_OP_ABORTED]` | A batch-level failure aborted the edit before writing (the base read failed, a cross-file member could not be prepared, the file changed or was deleted during the batch, or the composed result exceeded a limit). Nothing was written; a call whose own request is invalid is left out of the batch instead. Fix the cause and retry, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
490
+ | `[E_OP_ABORTED]` | The edit was aborted before writing: the file changed or was deleted after it was read (a single edit or a cross-file move reports the file; a batch reports the batch and the failing call), the base read failed, a cross-file member could not be prepared, or the composed result exceeded a limit. Nothing was written; a call whose own request is invalid is left out of the batch instead. Fix the cause and retry, otherwise call `read` for fresh anchors and retry. The batch abort names the failing call and its error code when one is known. |
491
491
  | `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences or look-around, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
492
492
  | `[E_GREP_TIMEOUT]` | `grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
493
493
 
package/index.ts CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  setDisableOnModelsFromText,
34
34
  } from "./src/config";
35
35
  import { loadHashStore, pruneMissing } from "./src/hash-store";
36
- import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
36
+ import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, sessionContextReadable, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
37
37
  import { serveRows } from "./src/served";
38
38
  import { finalizeTurn, planAssistantMessage } from "./src/batch";
39
39
  import { currentEditFlags } from "./src/edit-common";
@@ -46,6 +46,11 @@ import { valAccess } from "./src/validation";
46
46
  import { splitLines } from "./src/utils";
47
47
  import { AUTO_READ_ALL_CUSTOM_TYPE } from "./src/constants";
48
48
 
49
+ interface PendingRegistrySession {
50
+ key: string | undefined;
51
+ alive: () => boolean;
52
+ }
53
+
49
54
  export default function (pi: ExtensionAPI): void {
50
55
  regRead(pi);
51
56
 
@@ -72,7 +77,7 @@ export default function (pi: ExtensionAPI): void {
72
77
  let grepFlavorCwd: string | undefined;
73
78
  const baseAnchorTools = new Set<string>();
74
79
  let gateApplied = false;
75
- const pendingRegistrySessionKeys: Array<string | undefined> = [];
80
+ const pendingRegistrySessions: PendingRegistrySession[] = [];
76
81
 
77
82
  async function refreshEditTools(): Promise<void> {
78
83
  try {
@@ -145,8 +150,26 @@ export default function (pi: ExtensionAPI): void {
145
150
  pi.setActiveTools([...new Set([...base, ...enabled])]);
146
151
  }
147
152
 
148
- async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
149
- pendingRegistrySessionKeys.push(sessionKeyFor(ctx));
153
+ function captureRegistrySession(ctx: AnchorSessionContext | undefined): void {
154
+ const key = sessionKeyFor(ctx);
155
+ const entry: PendingRegistrySession = {
156
+ key,
157
+ alive: () => sessionContextReadable(ctx),
158
+ };
159
+ const index = key === undefined ? -1 : pendingRegistrySessions.findIndex((candidate) => candidate.key === key);
160
+ if (index >= 0) pendingRegistrySessions[index] = entry;
161
+ else pendingRegistrySessions.push(entry);
162
+ }
163
+
164
+ function releaseCapturedRegistrySession(key: string): void {
165
+ const index = pendingRegistrySessions.findIndex((entry) => entry.key === key);
166
+ if (index >= 0) pendingRegistrySessions.splice(index, 1);
167
+ releaseRegistrySession(key);
168
+ }
169
+
170
+ async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T | undefined> {
171
+ if (!sessionContextReadable(ctx)) return undefined;
172
+ captureRegistrySession(ctx);
150
173
  return withAnchorSession(ctx, fn);
151
174
  }
152
175
 
@@ -197,19 +220,38 @@ export default function (pi: ExtensionAPI): void {
197
220
  }
198
221
  }));
199
222
 
200
- pi.on("session_shutdown", async () => {
223
+ pi.on("session_shutdown", async (_event, ctx) => {
201
224
  try {
202
- const key = pendingRegistrySessionKeys.shift();
203
- if (key !== undefined) releaseRegistrySession(key);
225
+ let key: string | undefined;
226
+ let readable = true;
227
+ try {
228
+ key = sessionKeyFor(ctx);
229
+ } catch {
230
+ readable = false;
231
+ }
232
+ if (readable && key !== undefined) {
233
+ releaseCapturedRegistrySession(key);
234
+ return;
235
+ }
236
+ if (readable) {
237
+ const dead = pendingRegistrySessions.findIndex((entry) => entry.key === undefined && !entry.alive());
238
+ const fallback = pendingRegistrySessions.findIndex((entry) => entry.key === undefined);
239
+ const index = dead >= 0 ? dead : fallback;
240
+ if (index >= 0) pendingRegistrySessions.splice(index, 1);
241
+ return;
242
+ }
243
+ const dead = pendingRegistrySessions.findIndex((entry) => !entry.alive());
244
+ const entry = dead >= 0 ? pendingRegistrySessions.splice(dead, 1)[0] : pendingRegistrySessions.shift();
245
+ if (entry?.key !== undefined) releaseRegistrySession(entry.key);
204
246
  } catch (error) {
205
247
  console.error("Failed to release anchor registry session:", error);
206
248
  }
207
249
  });
208
- pi.on("model_select", async (event, ctx) => withAnchorSession(ctx, async () => {
250
+ pi.on("model_select", async (event, ctx) => withRegistrySession(ctx, async () => {
209
251
  await syncModelGate(event.model, ctx.cwd);
210
252
  }));
211
253
 
212
- pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
254
+ pi.on("before_agent_start", async (_event, ctx) => withRegistrySession(ctx, async () => {
213
255
  await syncModelGate(ctx.model, ctx.cwd);
214
256
  if (autoReadAll === "off" || autoReadAllInjected) return;
215
257
  if (modelDisabled(ctx.model, disableOnModels)) return;
@@ -287,12 +329,12 @@ export default function (pi: ExtensionAPI): void {
287
329
 
288
330
  pi.registerCommand("clear-anchors", {
289
331
  description: "Clear the session's anchor claims (path-free resolution state); anchors are re-claimed on the next read",
290
- handler: async (_args, ctx) => withAnchorSession(ctx, async () => {
332
+ handler: async (_args, ctx) => withRegistrySession(ctx, async () => {
291
333
  clearRegistry();
292
334
  ctx.ui.notify(`Anchor claims cleared for this session`, "info");
293
335
  }),
294
336
  });
295
- pi.on("message_end", async (event, ctx) => withAnchorSession(ctx, async () => {
337
+ pi.on("message_end", async (event, ctx) => withRegistrySession(ctx, async () => {
296
338
  try {
297
339
  await planAssistantMessage(event.message, ctx.cwd);
298
340
  } catch (error) {
@@ -307,7 +349,7 @@ export default function (pi: ExtensionAPI): void {
307
349
  console.error("Failed to finalize edit batch:", error);
308
350
  }
309
351
  });
310
- pi.on("tool_result", async (event, ctx) => withAnchorSession(ctx, async () => {
352
+ pi.on("tool_result", async (event, ctx) => withRegistrySession(ctx, async () => {
311
353
  if (event.isError) return;
312
354
  const gated = modelDisabled(ctx.model, disableOnModels);
313
355
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.4.4",
3
+ "version": "6.4.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",
@@ -406,7 +406,7 @@ async function ensureRegistryForKey(key: string, sessionFile: string | undefined
406
406
  try {
407
407
  await promise;
408
408
  } finally {
409
- pendingInits.delete(key);
409
+ if (pendingInits.get(key) === promise) pendingInits.delete(key);
410
410
  }
411
411
  }
412
412
 
@@ -435,6 +435,15 @@ function sessionFileFor(ctx: AnchorSessionContext | undefined): string | undefin
435
435
  return typeof sessionFile === "string" && sessionFile.length > 0 ? sessionFile : undefined;
436
436
  }
437
437
 
438
+ export function sessionContextReadable(ctx: AnchorSessionContext | undefined): boolean {
439
+ try {
440
+ void ctx?.sessionManager;
441
+ return true;
442
+ } catch {
443
+ return false;
444
+ }
445
+ }
446
+
438
447
  export async function withAnchorSession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
439
448
  const key = sessionKeyFor(ctx);
440
449
  if (key === undefined) return fn();
@@ -712,6 +721,9 @@ export function ownersForPath(path: string): Map<string, string> {
712
721
  export function ensureRegistry(): void {
713
722
  const key = activeKey();
714
723
  if (key && registries.has(key)) return;
724
+ if (sessionScope.getStore() !== undefined) {
725
+ throw new Error("[E_REGISTRY] The anchor registry for this session is no longer available; the session was replaced or reloaded. Retry the operation in the new session.");
726
+ }
715
727
  initRegistry(undefined).catch(() => undefined);
716
728
  }
717
729
 
@@ -973,8 +985,18 @@ export async function allocateFileAnchors(
973
985
  if (previousState && snapshotMatchesAllocation(registry, path, previousState.contentChecksum)) {
974
986
  prevAnchors = previousState.anchors;
975
987
  prevChecksums = previousState.checksums;
976
- if (!prevChecksums && previousState.contentChecksum === contentChecksum(content)) {
977
- prevChecksums = checksums;
988
+ if (!prevChecksums) {
989
+ if (previousState.contentChecksum === contentChecksum(content)) {
990
+ prevChecksums = checksums;
991
+ } else if (!shadow) {
992
+ const state = current();
993
+ const stale = state && prevAnchors ? prevAnchors.filter((anchor) => state.owned.get(anchor)?.path === path) : [];
994
+ if (state && stale.length > 0) {
995
+ const usedAt = state.lastUsedAt.get(path);
996
+ freeAnchors(path, stale);
997
+ if (usedAt !== undefined) state.lastUsedAt.set(path, usedAt);
998
+ }
999
+ }
978
1000
  }
979
1001
  }
980
1002
  }
@@ -3,6 +3,7 @@ import { lstat, open, readdir } from "node:fs/promises";
3
3
  import { join, relative, resolve, sep } from "node:path";
4
4
  import { formatSize } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
+ AUTO_READ_ALL_DIR_BUDGET_SHARE,
6
7
  AUTO_READ_ALL_MAX_BUDGET_BYTES,
7
8
  AUTO_READ_ALL_MAX_FILE_BYTES,
8
9
  AUTO_READ_ALL_MAX_FILES,
@@ -108,6 +109,137 @@ function isExcludedByPattern(baseLower: string): boolean {
108
109
  || baseLower.startsWith("coreui-icons.")
109
110
  || baseLower === "coreui.css";
110
111
  }
112
+
113
+ const MANIFEST_BASE_NAMES = new Set([
114
+ "package.json",
115
+ "tsconfig.json",
116
+ "jsconfig.json",
117
+ "pyproject.toml",
118
+ "setup.py",
119
+ "setup.cfg",
120
+ "requirements.txt",
121
+ "pipfile",
122
+ "cargo.toml",
123
+ "go.mod",
124
+ "gemfile",
125
+ "rakefile",
126
+ "makefile",
127
+ "gnumakefile",
128
+ "cmakelists.txt",
129
+ "meson.build",
130
+ "build.gradle",
131
+ "build.gradle.kts",
132
+ "settings.gradle",
133
+ "settings.gradle.kts",
134
+ "pom.xml",
135
+ "mix.exs",
136
+ "composer.json",
137
+ "dockerfile",
138
+ "docker-compose.yml",
139
+ "docker-compose.yaml",
140
+ "compose.yml",
141
+ "compose.yaml",
142
+ "readme.md",
143
+ "readme.rst",
144
+ "readme.txt",
145
+ "readme",
146
+ ]);
147
+
148
+ const SOURCE_TOP_LEVEL_NAMES = new Set([
149
+ "src",
150
+ "source",
151
+ "lib",
152
+ "libs",
153
+ "app",
154
+ "apps",
155
+ "packages",
156
+ "server",
157
+ "client",
158
+ "web",
159
+ "api",
160
+ "cmd",
161
+ "internal",
162
+ "pkg",
163
+ "core",
164
+ "modules",
165
+ "components",
166
+ "services",
167
+ "plugins",
168
+ "include",
169
+ ]);
170
+
171
+ const NON_SOURCE_TOP_LEVEL_NAMES = new Set([
172
+ "test",
173
+ "tests",
174
+ "spec",
175
+ "specs",
176
+ "__tests__",
177
+ "__mocks__",
178
+ "docs",
179
+ "doc",
180
+ "documentation",
181
+ "examples",
182
+ "example",
183
+ "samples",
184
+ "sample",
185
+ "fixtures",
186
+ "benchmark",
187
+ "benchmarks",
188
+ "bench",
189
+ "e2e",
190
+ "scripts",
191
+ "assets",
192
+ "static",
193
+ "public",
194
+ "coverage",
195
+ "migrations",
196
+ "locales",
197
+ "i18n",
198
+ "styles",
199
+ ]);
200
+
201
+ function topSegmentOf(path: string): string | undefined {
202
+ const index = path.indexOf("/");
203
+ return index < 0 ? undefined : path.slice(0, index);
204
+ }
205
+
206
+ function detectSourceTopLevel(files: readonly string[]): Set<string> {
207
+ const counts = new Map<string, number>();
208
+ for (const file of files) {
209
+ const top = topSegmentOf(file)?.toLowerCase();
210
+ if (top === undefined || NON_SOURCE_TOP_LEVEL_NAMES.has(top)) continue;
211
+ counts.set(top, (counts.get(top) ?? 0) + 1);
212
+ }
213
+ let dominant: string | undefined;
214
+ let dominantCount = 0;
215
+ for (const [name, count] of counts) {
216
+ if (count > dominantCount) {
217
+ dominant = name;
218
+ dominantCount = count;
219
+ }
220
+ }
221
+ const source = new Set(SOURCE_TOP_LEVEL_NAMES);
222
+ if (dominant !== undefined) source.add(dominant);
223
+ return source;
224
+ }
225
+
226
+ function priorityOf(file: string, changed: ReadonlySet<string>, sourceTopLevel: ReadonlySet<string>): number {
227
+ if (changed.has(file)) return 0;
228
+ if (MANIFEST_BASE_NAMES.has(baseNameOf(file).toLowerCase())) return 1;
229
+ const top = topSegmentOf(file)?.toLowerCase();
230
+ if (top !== undefined && sourceTopLevel.has(top)) return 2;
231
+ return 3;
232
+ }
233
+
234
+ function rankFiles(files: readonly string[], changed: ReadonlySet<string>, sourceTopLevel: ReadonlySet<string>): string[] {
235
+ return [...files].sort((a, b) => {
236
+ const byPriority = priorityOf(a, changed, sourceTopLevel) - priorityOf(b, changed, sourceTopLevel);
237
+ if (byPriority !== 0) return byPriority;
238
+ if (a === b) return 0;
239
+ return a < b ? -1 : 1;
240
+ });
241
+ }
242
+
111
243
  export function normalizeAutoReadAllIgnoreList(entries: readonly string[] | undefined): string[] {
112
244
  const seen = new Set<string>();
113
245
  const out: string[] = [];
@@ -228,6 +360,36 @@ async function listFromGit(cwd: string): Promise<string[] | undefined> {
228
360
  return result.stdout.split("\0").filter((entry) => entry.length > 0);
229
361
  }
230
362
 
363
+ async function listChangedFiles(cwd: string): Promise<Set<string>> {
364
+ const changed = new Set<string>();
365
+ const prefixResult = await runCommand("git", ["rev-parse", "--show-prefix"], cwd);
366
+ const prefix = prefixResult.code === 0 ? toPosix(prefixResult.stdout.trim()) : "";
367
+ const result = await runCommand("git", ["status", "--porcelain", "-z", "-uall"], cwd);
368
+ if (result.code !== 0) return changed;
369
+ const fields = result.stdout.split("\0");
370
+ for (let index = 0; index < fields.length; index += 1) {
371
+ const field = fields[index];
372
+ if (field === undefined || field.length < 4) continue;
373
+ const status = field.slice(0, 2);
374
+ addChangedPath(changed, field.slice(3), prefix);
375
+ if (status.includes("R") || status.includes("C")) {
376
+ index += 1;
377
+ const source = fields[index];
378
+ if (source !== undefined && source.length > 0) addChangedPath(changed, source, prefix);
379
+ }
380
+ }
381
+ return changed;
382
+ }
383
+
384
+ function addChangedPath(changed: Set<string>, path: string, prefix: string): void {
385
+ const posix = toPosix(path);
386
+ if (prefix.length > 0 && posix.startsWith(prefix)) {
387
+ changed.add(posix.slice(prefix.length));
388
+ return;
389
+ }
390
+ changed.add(posix);
391
+ }
392
+
231
393
  async function listFromRg(cwd: string): Promise<string[] | undefined> {
232
394
  let rgPath: string;
233
395
  try {
@@ -331,16 +493,19 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
331
493
  const unique = [...new Set(candidates.map(toPosix))].sort();
332
494
  const includable: string[] = [];
333
495
  let skippedByName = 0;
496
+ let skippedBinary = 0;
497
+ let skippedLarge = 0;
498
+ let skippedOther = 0;
334
499
  for (const file of unique) {
335
500
  const baseLower = baseNameOf(file).toLowerCase();
336
501
  if (EXCLUDED_NAME_SET.has(baseLower) || isExcludedBySegment(file) || isExcludedByPattern(baseLower) || isExcludedByCustomIgnore(file, customIgnore)) skippedByName += 1;
502
+ else if (IMAGE_EXTENSIONS.has(extensionOf(file))) skippedBinary += 1;
337
503
  else includable.push(file);
338
504
  }
339
- const scanWindow = includable.slice(0, AUTO_READ_ALL_MAX_FILES * SCAN_LIMIT_MULTIPLIER);
505
+ const changed = source === "git" ? await listChangedFiles(cwd) : new Set<string>();
506
+ const sourceTopLevel = detectSourceTopLevel(includable);
507
+ const scanWindow = rankFiles(includable, changed, sourceTopLevel).slice(0, AUTO_READ_ALL_MAX_FILES * SCAN_LIMIT_MULTIPLIER);
340
508
  const sized: string[] = [];
341
- let skippedBinary = 0;
342
- let skippedLarge = 0;
343
- let skippedOther = 0;
344
509
 
345
510
  await forEachLimit(scanWindow, SCAN_CONCURRENCY, async (file) => {
346
511
  let stats;
@@ -358,10 +523,6 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
358
523
  skippedLarge += 1;
359
524
  return;
360
525
  }
361
- if (IMAGE_EXTENSIONS.has(extensionOf(file))) {
362
- skippedBinary += 1;
363
- return;
364
- }
365
526
  sized.push(file);
366
527
  });
367
528
 
@@ -372,8 +533,7 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
372
533
  else if (nul) skippedBinary += 1;
373
534
  else textual.push(file);
374
535
  });
375
- textual.sort();
376
- const files = textual.slice(0, AUTO_READ_ALL_MAX_FILES);
536
+ const files = rankFiles(textual, changed, sourceTopLevel).slice(0, AUTO_READ_ALL_MAX_FILES);
377
537
 
378
538
  return { files, source, discovered: unique.length, skippedBinary, skippedLarge, skippedOther, skippedByName };
379
539
  }
@@ -430,34 +590,52 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
430
590
  const outline = isOutlineAutoReadAll(mode);
431
591
  const sections: AutoReadAllSection[] = [];
432
592
  const omitted: string[] = [];
593
+ const backlog: string[] = [];
594
+ const usedByDir = new Map<string, number>();
595
+ const dirBudget = Math.max(1, Math.floor(budgetBytes * AUTO_READ_ALL_DIR_BUDGET_SHARE));
433
596
  let bytes = 0;
434
597
  let completeFiles = 0;
435
- for (const file of discovery.files) {
598
+
599
+ const take = async (file: string, respectDirBudget: boolean): Promise<void> => {
436
600
  if (sections.length > 0 && bytes >= budgetBytes) {
437
601
  omitted.push(file);
438
- continue;
602
+ return;
439
603
  }
440
- if (!outline && sections.length > 0) {
604
+ const group = topSegmentOf(file) ?? ".";
605
+ const used = usedByDir.get(group) ?? 0;
606
+ if (sections.length > 0) {
441
607
  const size = await candidateFileBytes(cwd, file);
442
608
  if (size !== undefined && bytes + size > budgetBytes) {
443
609
  omitted.push(file);
444
- continue;
610
+ return;
611
+ }
612
+ if (respectDirBudget && size !== undefined && used + size > dirBudget) {
613
+ backlog.push(file);
614
+ return;
445
615
  }
446
616
  }
447
617
  const section = await renderFile(file, cwd, outline);
448
618
  if (section === undefined) {
449
619
  omitted.push(file);
450
- continue;
620
+ return;
451
621
  }
452
622
  const sectionBytes = Buffer.byteLength(section.text, "utf-8") + 1;
453
623
  if (sections.length > 0 && bytes + sectionBytes > budgetBytes) {
454
624
  omitted.push(file);
455
- continue;
625
+ return;
626
+ }
627
+ if (sections.length > 0 && respectDirBudget && used + sectionBytes > dirBudget) {
628
+ backlog.push(file);
629
+ return;
456
630
  }
457
631
  sections.push(section);
458
632
  bytes += sectionBytes;
633
+ usedByDir.set(group, used + sectionBytes);
459
634
  if (section.complete) completeFiles += 1;
460
- }
635
+ };
636
+
637
+ for (const file of discovery.files) await take(file, true);
638
+ for (const file of backlog) await take(file, false);
461
639
  if (sections.length === 0) return undefined;
462
640
  const sectionTexts = sections.map((section) => section.text);
463
641
  const coverage = `[coverage: ${completeFiles} complete]`;
package/src/batch.ts CHANGED
@@ -505,7 +505,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
505
505
  edits_noop: piece.noop ? 1 : 0,
506
506
  warnings: 0,
507
507
  classification: piece.noop ? "noop" : "applied",
508
- ...(piece.noop ? {} : { added_lines: added, removed_lines: piece.end - piece.start + 1 }),
508
+ ...(piece.noop ? {} : { added_lines: added, removed_lines: Math.max(0, piece.end - piece.start + 1 - piece.foldedLines) }),
509
509
  };
510
510
  const structuredContent: EditStructured = {
511
511
  ok: true,
@@ -1102,7 +1102,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
1102
1102
  let added = 0;
1103
1103
  let removed = 0;
1104
1104
  for (const piece of appliedPieces) {
1105
- removed += piece.end - piece.start + 1;
1105
+ removed += Math.max(0, piece.end - piece.start + 1 - piece.foldedLines);
1106
1106
  added += Math.max(0, piece.newLines.length - piece.foldedLines);
1107
1107
  }
1108
1108
  const verb = batchVerb(runtime);
package/src/commit.ts CHANGED
@@ -14,6 +14,28 @@ import { spanForEdit } from "./replace";
14
14
  import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
15
15
  import { applyEndingOverrides, joinSeparators, separatorsForSpans } from "./line-endings";
16
16
  import { toEditVerb } from "./structured";
17
+ export type FileStateSinceRead = "unchanged" | "deleted" | "changed";
18
+
19
+ export async function fileStateSinceRead(
20
+ mutationTargetPath: string,
21
+ displayPath: string,
22
+ expectedNormalized: string,
23
+ signal?: AbortSignal,
24
+ ): Promise<FileStateSinceRead> {
25
+ abortIf(signal);
26
+ let raw: string | undefined;
27
+ try {
28
+ raw = await readFile(mutationTargetPath, "utf-8");
29
+ } catch (error) {
30
+ const code = errCode(error);
31
+ if (code === "ENOENT") raw = undefined;
32
+ else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${displayPath}`);
33
+ else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${displayPath}`);
34
+ else throw mapIoError(error, "read", displayPath);
35
+ }
36
+ if (raw === undefined) return "deleted";
37
+ return toLF(stripBOM(raw).text) === expectedNormalized ? "unchanged" : "changed";
38
+ }
17
39
  export interface CommitMeta {
18
40
  editAnchors?: [string, string];
19
41
  anchorCarry?: number;
@@ -91,21 +113,11 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
91
113
  );
92
114
  }
93
115
 
94
- abortIf(signal);
95
- let currentRaw: string | undefined;
96
- try {
97
- currentRaw = await readFile(mutationTargetPath, "utf-8");
98
- } catch (error) {
99
- const code = errCode(error);
100
- if (code === "ENOENT") currentRaw = undefined;
101
- else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${path}`);
102
- else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${path}`);
103
- else throw mapIoError(error, "read", path);
104
- }
105
- if (currentRaw === undefined) {
116
+ const state = await fileStateSinceRead(mutationTargetPath, path, pipe.originalNormalized, signal);
117
+ if (state === "deleted") {
106
118
  throw new Error(`[E_OP_ABORTED] Edit aborted: the file was deleted after the edit started.`);
107
119
  }
108
- if (toLF(stripBOM(currentRaw).text) !== pipe.originalNormalized) {
120
+ if (state === "changed") {
109
121
  throw new Error(`[E_OP_ABORTED] Edit aborted: the file changed after the edit started. Call read for fresh anchors and retry.`);
110
122
  }
111
123
  const undo = await saveUndo(mutationTargetPath, {
@@ -141,7 +153,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
141
153
  firstChangedLine: pipe.firstChangedLine,
142
154
  lastChangedLine: pipe.lastChangedLine,
143
155
  addedLines: Math.max(0, pipe.totalAddedLines - (meta.foldedAnchorLines ?? 0)),
144
- removedLines: pipe.totalRemovedLines,
156
+ removedLines: Math.max(0, pipe.totalRemovedLines - (meta.foldedAnchorLines ?? 0)),
145
157
  };
146
158
 
147
159
  let resultHashes: string[];
package/src/constants.ts CHANGED
@@ -36,3 +36,4 @@ export const AUTO_READ_ALL_MAX_FILES = 500;
36
36
  export const AUTO_READ_ALL_MAX_FILE_BYTES = 200_000;
37
37
  export const AUTO_READ_ALL_MIN_BUDGET_BYTES = 200_000;
38
38
  export const AUTO_READ_ALL_MAX_BUDGET_BYTES = 2_000_000;
39
+ export const AUTO_READ_ALL_DIR_BUDGET_SHARE = 0.4;
package/src/copy-move.ts CHANGED
@@ -3,7 +3,7 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, withFil
3
3
  import { Type } from "typebox";
4
4
  import { constants } from "node:fs";
5
5
  import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type ReplaceDetails } from "./replace";
6
- import { commitEdit } from "./commit";
6
+ import { commitEdit, fileStateSinceRead } from "./commit";
7
7
  import { readNormFile, safeSnapId, type NormFile } from "./file-reader";
8
8
  import { preEditSnapshotFor } from "./pre-edit-snapshot";
9
9
  import {
@@ -377,6 +377,17 @@ async function commitMovePair(input: {
377
377
  warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
378
378
  }
379
379
 
380
+ const destinationState = await fileStateSinceRead(input.destination.mutationTargetPath, input.destination.displayPath, input.destination.pipe.originalNormalized, input.signal);
381
+ if (destinationState !== "unchanged") {
382
+ const verb = destinationState === "deleted" ? "was deleted" : "changed";
383
+ throw new Error(`[E_OP_ABORTED] Edit aborted: ${input.destination.displayPath} ${verb} after the move started. Call read for fresh anchors and retry.`);
384
+ }
385
+ const sourceState = await fileStateSinceRead(input.source.mutationTargetPath, input.source.displayPath, input.source.pipe.originalNormalized, input.signal);
386
+ if (sourceState !== "unchanged") {
387
+ const verb = sourceState === "deleted" ? "was deleted" : "changed";
388
+ throw new Error(`[E_OP_ABORTED] Edit aborted: ${input.source.displayPath} ${verb} after the move started. Call read for fresh anchors and retry.`);
389
+ }
390
+
380
391
  const undoDestination = await saveUndo(input.destination.mutationTargetPath, pairEntry(input.destination.pipe, destinationSeparators));
381
392
  if (!undoDestination.persisted) {
382
393
  throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${input.destination.displayPath}. Nothing was written.`);
@@ -451,7 +462,7 @@ async function commitMovePair(input: {
451
462
  firstChangedLine: input.destination.pipe.firstChangedLine,
452
463
  lastChangedLine: input.destination.pipe.lastChangedLine,
453
464
  addedLines: Math.max(0, input.destination.pipe.totalAddedLines - input.destination.foldedAnchorLines),
454
- removedLines: input.destination.pipe.totalRemovedLines,
465
+ removedLines: Math.max(0, input.destination.pipe.totalRemovedLines - input.destination.foldedAnchorLines),
455
466
  },
456
467
  ...(destinationSpan !== undefined ? { spans: [destinationSpan] } : {}),
457
468
  }, "moved", contextLines);
package/src/write-hook.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { HASH_CLASS } from "./hashline/alphabet";
3
3
  import { HASH_SEP } from "./hashline/hash";
4
- import { servedForPath, withAnchorSession } from "./anchor-registry";
4
+ import { sessionContextReadable, servedForPath, withAnchorSession, type AnchorSessionContext } from "./anchor-registry";
5
5
  import { resolveInCwd } from "./fs-write";
6
6
  import { abortIf, splitLines, isRec } from "./utils";
7
7
  import type { ModelLike } from "./model-gate";
@@ -31,8 +31,12 @@ export async function servedHashEchoDenial(rawPath: string, content: string, cwd
31
31
  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.`;
32
32
  }
33
33
 
34
+ function withReadableSession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T | undefined> {
35
+ return sessionContextReadable(ctx) ? withAnchorSession(ctx, fn) : Promise.resolve(undefined);
36
+ }
37
+
34
38
  export function registerWriteHook(pi: ExtensionAPI, isModelDisabled?: (model: ModelLike | undefined) => boolean): void {
35
- pi.on("tool_call", async (event, ctx) => withAnchorSession(ctx, async () => {
39
+ pi.on("tool_call", async (event, ctx) => withReadableSession(ctx, async () => {
36
40
  if (event.toolName !== "write") return;
37
41
  if (isModelDisabled?.(ctx.model)) return;
38
42
  const input = event.input as Record<string, unknown> | undefined;