pi-hashline-edit-pro 4.4.3 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +312 -63
  2. package/index.ts +22 -6
  3. package/package.json +4 -3
  4. package/prompts/copy-guidelines.md +1 -0
  5. package/prompts/copy-snippet.md +1 -0
  6. package/prompts/copy.md +3 -0
  7. package/prompts/grep.md +1 -1
  8. package/prompts/insert.md +1 -1
  9. package/prompts/move-guidelines.md +1 -0
  10. package/prompts/move-snippet.md +1 -0
  11. package/prompts/move.md +3 -0
  12. package/prompts/read-guidelines.md +2 -1
  13. package/prompts/replace-guidelines.md +0 -2
  14. package/prompts/replace.md +1 -1
  15. package/prompts/undo-last-change-guidelines.md +2 -1
  16. package/prompts/undo-last-change-snippet.md +1 -1
  17. package/prompts/undo-last-change.md +1 -1
  18. package/src/anchor-registry.ts +128 -39
  19. package/src/auto-read-all.ts +3 -1
  20. package/src/batch.ts +35 -7
  21. package/src/commit.ts +22 -4
  22. package/src/config-ui.ts +2 -1
  23. package/src/config.ts +6 -1
  24. package/src/constants.ts +4 -1
  25. package/src/copy-move.ts +793 -0
  26. package/src/edit-common.ts +21 -1
  27. package/src/file-reader.ts +4 -0
  28. package/src/grep.ts +3 -1
  29. package/src/hash-store/validation.ts +22 -0
  30. package/src/hash-store.ts +33 -5
  31. package/src/hashline/apply.ts +8 -3
  32. package/src/hashline/index.ts +3 -0
  33. package/src/hashline/parse.ts +22 -10
  34. package/src/hashline/resolve.ts +23 -8
  35. package/src/insert.ts +28 -10
  36. package/src/line-endings.ts +151 -0
  37. package/src/paths.ts +23 -3
  38. package/src/payload-contract.ts +29 -1
  39. package/src/read.ts +7 -5
  40. package/src/replace-render.ts +4 -4
  41. package/src/replace-undo.ts +13 -4
  42. package/src/replace.ts +17 -5
  43. package/src/utils.ts +40 -5
package/index.ts CHANGED
@@ -3,6 +3,7 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-
3
3
  import { initHasher } from "./src/hashline";
4
4
  import { regReplace } from "./src/replace";
5
5
  import { regInsert } from "./src/insert";
6
+ import { regCopy, regMove } from "./src/copy-move";
6
7
  import { regGrep } from "./src/grep";
7
8
  import { regUndo, clearUndo } from "./src/replace-undo";
8
9
  import { regRead, fmtReadPreview } from "./src/read";
@@ -18,13 +19,14 @@ import {
18
19
  toggleAutoRead,
19
20
  cycleAutoReadAllMode,
20
21
  toggleAnchorGrep,
22
+ toggleCopyMove,
21
23
  toggleRequirePath,
22
24
  toggleStrictInput,
23
25
  adjustDiffContextLines,
24
26
  setAutoReadAllIgnoreFromText,
25
27
  } from "./src/config";
26
28
  import { loadHashStore, pruneMissing } from "./src/hash-store";
27
- import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
29
+ import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
28
30
  import { serveRows } from "./src/served";
29
31
  import { finalizeTurn, planAssistantMessage } from "./src/batch";
30
32
  import { currentEditFlags } from "./src/edit-common";
@@ -42,6 +44,8 @@ export default function (pi: ExtensionAPI): void {
42
44
 
43
45
  regReplace(pi);
44
46
  regInsert(pi);
47
+ regCopy(pi);
48
+ regMove(pi);
45
49
  regGrep(pi);
46
50
  regUndo(pi);
47
51
  registerWriteHook(pi);
@@ -58,6 +62,8 @@ export default function (pi: ExtensionAPI): void {
58
62
  regRead(pi, flags);
59
63
  regReplace(pi, flags);
60
64
  regInsert(pi, flags);
65
+ regCopy(pi, flags);
66
+ regMove(pi, flags);
61
67
  regUndo(pi, flags);
62
68
  } catch (error) {
63
69
  console.error("Failed to refresh edit tools:", error);
@@ -90,9 +96,11 @@ export default function (pi: ExtensionAPI): void {
90
96
  autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
91
97
  await refreshEditTools();
92
98
  pi.setActiveTools(
93
- pi.getActiveTools().filter((t) =>
94
- config.anchorGrepEnabled ? t !== "grep" : t !== "anchor_grep",
95
- ),
99
+ pi.getActiveTools().filter((t) => {
100
+ if (config.anchorGrepEnabled ? t === "grep" : t === "anchor_grep") return false;
101
+ if (config.copyMoveEnabled === false && (t === "copy" || t === "move")) return false;
102
+ return true;
103
+ }),
96
104
  );
97
105
  const debugValue = process.env.PI_HASHLINE_DEBUG;
98
106
  if (debugValue === "1" || debugValue === "true") {
@@ -125,7 +133,7 @@ export default function (pi: ExtensionAPI): void {
125
133
  }));
126
134
 
127
135
  pi.registerCommand("hashline-config", {
128
- description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, path, strict input)",
136
+ description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, path, strict input)",
129
137
  handler: async (_args, ctx) => {
130
138
  if (!ctx.hasUI) {
131
139
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
@@ -146,6 +154,11 @@ export default function (pi: ExtensionAPI): void {
146
154
  const active = pi.getActiveTools();
147
155
  pi.setActiveTools(enabled ? [...new Set([...active.filter((t) => t !== "grep"), "anchor_grep"])] : [...new Set([...active.filter((t) => t !== "anchor_grep"), ...(grepWasActive ? ["grep"] : [])])]);
148
156
  }
157
+ else if (key === "copyMoveEnabled") {
158
+ const enabled = await toggleCopyMove();
159
+ const active = pi.getActiveTools();
160
+ pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
161
+ }
149
162
  else if (key === "requirePath") await toggleRequirePath();
150
163
  else if (key === "strictInput") await toggleStrictInput();
151
164
  await refreshEditTools();
@@ -217,10 +230,11 @@ export default function (pi: ExtensionAPI): void {
217
230
  );
218
231
  const fileLines = splitLines(normalized);
219
232
  serveRows(absolutePath, fileHashes, fileLines, preview.servedHashes);
233
+ const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
220
234
  return {
221
235
  content: [
222
236
  ...(event.content ?? []),
223
- { type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${preview.text}` },
237
+ { type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${preview.text}${reclaimNotice !== undefined ? `\n\n${reclaimNotice}` : ""}` },
224
238
  ],
225
239
  };
226
240
  } catch (error) {
@@ -238,6 +252,8 @@ export default function (pi: ExtensionAPI): void {
238
252
  if (
239
253
  event.toolName !== "replace" &&
240
254
  event.toolName !== "insert" &&
255
+ event.toolName !== "copy" &&
256
+ event.toolName !== "move" &&
241
257
  event.toolName !== "undo_last_change"
242
258
  ) return;
243
259
  if (!autoRead) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.4.3",
3
+ "version": "4.5.0",
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",
@@ -41,12 +41,12 @@
41
41
  "dependencies": {
42
42
  "diff": "^9.0.0",
43
43
  "file-type": "^22.0.2",
44
- "typebox": "^1.3.7",
45
44
  "xxhash-wasm": "^1.1.0"
46
45
  },
47
46
  "peerDependencies": {
48
47
  "@earendil-works/pi-coding-agent": ">=0.84.0",
49
- "@earendil-works/pi-tui": "*"
48
+ "@earendil-works/pi-tui": "*",
49
+ "typebox": "*"
50
50
  },
51
51
  "engines": {
52
52
  "node": ">=22.19.0"
@@ -65,6 +65,7 @@
65
65
  "@types/node": "^24",
66
66
  "@vitest/coverage-v8": "^4.1.11",
67
67
  "eslint": "^10.9.1",
68
+ "typebox": "^1.3.7",
68
69
  "typescript": "^5.9.3",
69
70
  "typescript-eslint": "^8.68.0",
70
71
  "vitest": "^4.1.11"
@@ -0,0 +1 @@
1
+ - `copy`: the same anchor in `source_from` and `source_to` copies one line; copied lines get fresh anchors in the post-edit diff and the source rows keep theirs.
@@ -0,0 +1 @@
1
+ Copy lines to another position by anchor: `source_from`/`source_to` mark the range, the copy lands after `insert_after`; the anchors resolve both files
@@ -0,0 +1,3 @@
1
+ Copy a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to copy in the source file, and `insert_after` as the bare anchor of the destination line after which the copy goes. `source_from` and `source_to` may belong to a different file than `insert_after`: the lines are copied from the source file and inserted into the destination file, and an empty destination file is seeded with the copied lines. The source lines stay in place, and the copied lines are written exactly as the source file holds them.
2
+
3
+ Example: read served `Hasu│old` in `a.ts` and `Qwer│top` in `b.ts`. Call { "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" } copies `old` from `a.ts` into `b.ts` below `top`; the source `Hasu│old` row stays live.
package/prompts/grep.md CHANGED
@@ -1 +1 @@
1
- Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace or insert without a new read; the `=== path ===` header and line numbers locate the match. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When cut by match limit, raise `limit` (default 100); when cut by rows/bytes, refine `pattern` / narrow path.
1
+ Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace, insert, copy, or move without a new read; the `=== path ===` header and line numbers locate the match. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When cut by match limit, raise `limit` (default 100); when cut by rows/bytes, refine `pattern` / narrow path.
package/prompts/insert.md CHANGED
@@ -1,3 +1,3 @@
1
- Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes, no embedded newlines. Inserted lines are written exactly as given; nothing else in the file changes.
1
+ Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. Inserted lines are written exactly as given; nothing else in the file changes.
2
2
 
3
3
  Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
@@ -0,0 +1 @@
1
+ - `move`: a cross-file move records one undo entry per file — undo both sides or the moved lines stay duplicated or missing. Lines between source and target may be re-anchored.
@@ -0,0 +1 @@
1
+ Move lines to another position by anchor: `source_from`/`source_to` mark the range, it lands after `insert_after`; the anchors resolve both files
@@ -0,0 +1,3 @@
1
+ Move a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to move in the source file, and `insert_after` as the bare anchor of the destination line after which the range goes. `source_from` and `source_to` may belong to a different file than `insert_after`: the range is removed from the source file and inserted into the destination file in one call, and an empty destination file is seeded with the moved lines. `insert_after` must sit outside the source range when both anchors share a file; moving a range in one file to where it already sits reports no change.
2
+
3
+ Example: read served `Hasu│a` in `lib.ts` and `Qwer│top` in `app.ts`. Call { "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" } moves `a` from `lib.ts` into `app.ts` below `top`.
@@ -1,2 +1,3 @@
1
1
  - `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
2
- - `read`: `E_AUTO_READ_ALL` on an attached file means its content is still exactly as it was when attached at the start of this session.
2
+ - `read`: `E_AUTO_READ_ALL` on an attached file means its content is still exactly as it was when attached at the start of this session.
3
+ - `read`: `[W_ANCHOR_RECLAIMED]` means the session's anchor quota was tight and the listed files' anchors were freed; read a freed file again before editing it.
@@ -1,6 +1,4 @@
1
1
  - `replace`: `-anchor│` rows in a post-edit diff are dead anchors; only `+anchor│` and ` anchor│` rows are live. Check the post-edit diff before the next turn's edits on that file.
2
2
  - `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone; its file's batch still commits.
3
- - `replace`: `[E_STALE_ANCHOR]` means the anchor is not owned in this session: call `read` on the file for fresh anchors. `[E_RANGE_STALE]` means a line in the range changed since it was shown, or was never shown: retry with the fresh anchors the error returns.
4
- - `replace`: `[E_BATCH_OVERLAP]` means two batch calls share lines: retry with disjoint ranges. `[E_OP_ABORTED]` means another batch call failed or the file changed: fix that call, then retry, or call `read` for fresh anchors.
5
3
  - `replace`: use `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
6
4
  - `replace`: `replacement_lines`: one string per line, `[""]` is one blank line, `[]` deletes. Pasted `anchor│` prefixes are stripped. Single line: same anchor for `remove_from` and `remove_to`.
@@ -1,4 +1,4 @@
1
- Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes and no embedded newlines; `[]` deletes the range.
1
+ Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. `[]` deletes the range.
2
2
 
3
3
  Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
4
4
 
@@ -1 +1,2 @@
1
- - `undo_last_change`: only the last `replace`/`insert` per file is undoable; a `write` clears it, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
1
+ - `undo_last_change`: only the last `replace`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
2
+ - `undo_last_change`: a cross-file `move` records one undo entry per file; undo both the source and the destination to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing.
@@ -1 +1 @@
1
- Single-level undo: reverts a file's last `replace` or `insert`
1
+ Single-level undo: reverts a file's last `replace`, `insert`, `copy`, or `move`
@@ -1 +1 @@
1
- Undo the last replace or insert on a file, restoring the previous content, BOM, and line endings. Use it when an edit removed or changed the wrong lines. If the file changed since that edit, the undo is refused with `[E_UNDO_STALE]` and nothing is modified: the record is kept, so verify the current state with read and stop instead of forcing the undo. A file deleted since the edit is restored. If the output says truncated, use read to see the full file.
1
+ Undo the last replace, insert, copy, or move on a file, restoring the previous content, BOM, and line endings. Use it when an edit removed or changed the wrong lines. If the file changed since that edit, the undo is refused with `[E_UNDO_STALE]` and nothing is modified: the record is kept, so verify the current state with read and stop instead of forcing the undo. A file deleted since the edit is restored. If the output says truncated, use read to see the full file.
@@ -11,7 +11,7 @@ import { errCode, splitLines } from "./utils";
11
11
  import * as Diff from "diff";
12
12
  import { lineChecksum } from "./hashline";
13
13
  import { getAllocatedState, persistSnapshot, type HashStore } from "./hash-store";
14
- import { ANCHOR_POOL_EXHAUSTED_PREFIX } from "./constants";
14
+ import { ANCHOR_POOL_EXHAUSTED_PREFIX, ANCHOR_RECLAIM_WARNING_CODE, MAX_RECLAIMED_PATHS_REPORTED } from "./constants";
15
15
 
16
16
  export type RegistryEvent =
17
17
  | { kind: "session"; sessionFile: string }
@@ -153,6 +153,10 @@ interface SessionState {
153
153
  everMinted: AnchorMintedSet;
154
154
  probe: number;
155
155
  allocatedChecksum: Map<string, string>;
156
+ lastUsedAt: Map<string, number>;
157
+ usageClock: number;
158
+ reclaimed: string[];
159
+ shadow?: boolean;
156
160
  }
157
161
 
158
162
  const SIDECAR_SUFFIX = ".registry.jsonl";
@@ -176,7 +180,7 @@ function newSessionState(seed?: string): SessionState {
176
180
  probe = (probe * 256 + byte) % ANCHOR_COUNT;
177
181
  }
178
182
  }
179
- return { owned: new Map(), served: new Map(), everMinted: new Set(), probe, allocatedChecksum: new Map() };
183
+ return { owned: new Map(), served: new Map(), everMinted: new Set(), probe, allocatedChecksum: new Map(), lastUsedAt: new Map(), usageClock: 0, reclaimed: [] };
180
184
  }
181
185
 
182
186
  function seedServedFromOwned(state: SessionState): void {
@@ -195,9 +199,12 @@ export function foldRegistryEvents(events: RegistryEvent[], seed?: string): Sess
195
199
  for (const event of events) {
196
200
  if (event.kind === "clear") {
197
201
  state.owned.clear();
202
+ state.lastUsedAt.clear();
198
203
  } else if (event.kind === "minted") {
199
204
  for (const anchor of event.anchors) state.everMinted.add(anchor);
200
205
  } else if (event.kind === "allocate") {
206
+ state.usageClock += 1;
207
+ state.lastUsedAt.set(event.path, state.usageClock);
201
208
  for (const [anchor, checksum] of event.rows) {
202
209
  state.owned.set(anchor, { path: event.path, checksum });
203
210
  state.everMinted.add(anchor);
@@ -213,9 +220,15 @@ export function foldRegistryEvents(events: RegistryEvent[], seed?: string): Sess
213
220
  state.owned.delete(anchor);
214
221
  }
215
222
  }
223
+ state.lastUsedAt.delete(event.path);
216
224
  }
217
225
  }
218
226
  }
227
+ const ownedPaths = new Set<string>();
228
+ for (const entry of state.owned.values()) ownedPaths.add(entry.path);
229
+ for (const path of [...state.lastUsedAt.keys()]) {
230
+ if (!ownedPaths.has(path)) state.lastUsedAt.delete(path);
231
+ }
219
232
  return state;
220
233
  }
221
234
 
@@ -468,32 +481,96 @@ function fingerprintIndex(state: SessionState, path: string): Map<string, string
468
481
 
469
482
  export const MINT_PROBE_LIMIT = 8192;
470
483
 
471
- export function mintAnchor(state: SessionState): string {
472
- for (let probe = 0; probe < MINT_PROBE_LIMIT; probe++) {
473
- state.probe = (state.probe + HASH_PROBE_STRIDE) % ANCHOR_COUNT;
474
- const candidate = anchorAt(state.probe);
475
- if (!state.owned.has(candidate) && !state.everMinted.has(candidate)) {
476
- return candidate;
484
+ function touchPath(state: SessionState, path: string): void {
485
+ state.usageClock += 1;
486
+ state.lastUsedAt.set(path, state.usageClock);
487
+ }
488
+
489
+ function hasOwnedPath(state: SessionState, path: string): boolean {
490
+ for (const entry of state.owned.values()) {
491
+ if (entry.path === path) return true;
492
+ }
493
+ return false;
494
+ }
495
+
496
+ function evictPath(state: SessionState, path: string): string[] {
497
+ const anchors: string[] = [];
498
+ for (const [anchor, entry] of [...state.owned]) {
499
+ if (entry.path === path) {
500
+ anchors.push(anchor);
501
+ state.owned.delete(anchor);
502
+ }
503
+ }
504
+ state.served.delete(path);
505
+ state.lastUsedAt.delete(path);
506
+ if (anchors.length > 0 && state === current()) {
507
+ appendEvent({ kind: "free", path, anchors });
508
+ }
509
+ return anchors;
510
+ }
511
+
512
+ export function reclaimAnchorSpace(state: SessionState, protectPath?: string): string | undefined {
513
+ if (state.shadow) return undefined;
514
+ let victim: string | undefined;
515
+ let victimTime = Number.POSITIVE_INFINITY;
516
+ for (const [, entry] of state.owned) {
517
+ if (entry.path === protectPath) continue;
518
+ const time = state.lastUsedAt.get(entry.path) ?? -1;
519
+ if (time < victimTime) {
520
+ victimTime = time;
521
+ victim = entry.path;
522
+ }
523
+ }
524
+ if (victim === undefined) return undefined;
525
+ evictPath(state, victim);
526
+ state.reclaimed.push(victim);
527
+ return victim;
528
+ }
529
+
530
+ export function mintAnchor(state: SessionState, protectPath?: string): string {
531
+ for (;;) {
532
+ for (let probe = 0; probe < MINT_PROBE_LIMIT; probe++) {
533
+ state.probe = (state.probe + HASH_PROBE_STRIDE) % ANCHOR_COUNT;
534
+ const candidate = anchorAt(state.probe);
535
+ if (!state.owned.has(candidate) && !state.everMinted.has(candidate)) {
536
+ return candidate;
537
+ }
477
538
  }
478
- }
479
- for (let step = 1; step <= ANCHOR_COUNT; step++) {
480
- const candidate = anchorAt((state.probe + step) % ANCHOR_COUNT);
481
- if (!state.owned.has(candidate)) {
482
- for (const served of state.served.values()) served.delete(candidate);
483
- return candidate;
539
+ for (let step = 1; step <= ANCHOR_COUNT; step++) {
540
+ const candidate = anchorAt((state.probe + step) % ANCHOR_COUNT);
541
+ if (!state.owned.has(candidate)) {
542
+ for (const served of state.served.values()) served.delete(candidate);
543
+ return candidate;
544
+ }
484
545
  }
546
+ if (reclaimAnchorSpace(state, protectPath) === undefined) break;
485
547
  }
486
548
  throw new Error(
487
549
  `${ANCHOR_POOL_EXHAUSTED_PREFIX}; use write for very large files.`,
488
550
  );
489
551
  }
490
552
 
553
+ export function takeReclaimedPaths(): string[] {
554
+ const state = current();
555
+ if (!state || state.reclaimed.length === 0) return [];
556
+ return state.reclaimed.splice(0, state.reclaimed.length);
557
+ }
558
+
559
+ export function formatAnchorReclaimNotice(paths: string[]): string | undefined {
560
+ const unique = [...new Set(paths)];
561
+ if (unique.length === 0) return undefined;
562
+ const shown = unique.slice(0, MAX_RECLAIMED_PATHS_REPORTED);
563
+ const more = unique.length - shown.length;
564
+ const list = more > 0 ? `${shown.join(", ")} (+${more} more)` : shown.join(", ");
565
+ return `${ANCHOR_RECLAIM_WARNING_CODE} The session's anchor quota is nearly exhausted; freed all anchors of ${list}, the files least recently read or edited. Read each freed file again before editing it; for read-heavy work, ask the user to run /clear-anchors or start a new session.`;
566
+ }
567
+
491
568
  export function allocateAnchor(path: string, checksum: string): string {
492
569
  const state = current();
493
570
  if (!state) {
494
571
  throw new Error("[E_REGISTRY] The anchor registry is not initialized; call initRegistry on session_start first.");
495
572
  }
496
- const anchor = mintAnchor(state);
573
+ const anchor = mintAnchor(state, path);
497
574
  state.everMinted.add(anchor);
498
575
  state.owned.set(anchor, { path, checksum });
499
576
  appendEvent({ kind: "allocate", path, rows: [[anchor, checksum]] });
@@ -505,26 +582,21 @@ export function allocateAnchor(path: string, checksum: string): string {
505
582
  export function freeAnchors(path: string, anchors?: string[]): void {
506
583
  const state = current();
507
584
  if (!state) return;
585
+ if (!anchors) {
586
+ evictPath(state, path);
587
+ return;
588
+ }
508
589
  const freed: string[] = [];
509
- if (anchors) {
510
- for (const anchor of anchors) {
511
- if (state.owned.has(anchor)) {
512
- freed.push(anchor);
513
- state.owned.delete(anchor);
514
- state.served.get(path)?.delete(anchor);
515
- }
590
+ for (const anchor of anchors) {
591
+ if (state.owned.has(anchor)) {
592
+ freed.push(anchor);
593
+ state.owned.delete(anchor);
594
+ state.served.get(path)?.delete(anchor);
516
595
  }
517
- } else {
518
- for (const [anchor, entry] of [...state.owned]) {
519
- if (entry.path === path) {
520
- freed.push(anchor);
521
- state.owned.delete(anchor);
522
- }
523
- }
524
- state.served.delete(path);
525
596
  }
526
597
  if (freed.length > 0) {
527
- appendEvent({ kind: "free", path, anchors: anchors ?? undefined });
598
+ if (!hasOwnedPath(state, path)) state.lastUsedAt.delete(path);
599
+ appendEvent({ kind: "free", path, anchors });
528
600
  }
529
601
  }
530
602
 
@@ -534,6 +606,8 @@ export function clearRegistry(): void {
534
606
  state.owned.clear();
535
607
  state.served.clear();
536
608
  state.allocatedChecksum.clear();
609
+ state.lastUsedAt.clear();
610
+ state.reclaimed.length = 0;
537
611
  appendEvent({ kind: "clear" });
538
612
  }
539
613
 
@@ -609,6 +683,10 @@ export function shadowStateFrom(state: SessionState): SessionState {
609
683
  everMinted: new ShadowMintedSet(state.everMinted),
610
684
  probe: state.probe,
611
685
  allocatedChecksum: state.allocatedChecksum,
686
+ lastUsedAt: new Map(state.lastUsedAt),
687
+ usageClock: state.usageClock,
688
+ reclaimed: [],
689
+ shadow: true,
612
690
  };
613
691
  }
614
692
 
@@ -721,7 +799,7 @@ export function alignOwnershipWithSpans(
721
799
  }
722
800
  for (let k = 0; k < span.replacementCount; k++) {
723
801
  if (replacement[k] !== undefined) continue;
724
- const anchor = mintAnchor(state);
802
+ const anchor = mintAnchor(state, path);
725
803
  const checksum = newChecksums[start + k]!;
726
804
  minted.push({ index: start + k, anchor, checksum });
727
805
  state.owned.set(anchor, { path, checksum });
@@ -769,7 +847,7 @@ export function alignOwnership(
769
847
  if (part.added) {
770
848
  for (let k = 0; k < count; k++) {
771
849
  const checksum = newChecksums[newIdx + k]!;
772
- const anchor = mintAnchor(state);
850
+ const anchor = mintAnchor(state, path);
773
851
  state.everMinted.add(anchor);
774
852
  state.owned.set(anchor, { path, checksum });
775
853
  minted.push({ index: newIdx + k, anchor, checksum });
@@ -792,7 +870,7 @@ export function alignOwnership(
792
870
  const entry = state.owned.get(anchor);
793
871
  const checksum = newChecksums[newIdx + k]!;
794
872
  if (entry && entry.path !== path) {
795
- const fresh = mintAnchor(state);
873
+ const fresh = mintAnchor(state, path);
796
874
  state.owned.set(fresh, { path, checksum });
797
875
  anchors[newIdx + k] = fresh;
798
876
  } else {
@@ -834,6 +912,7 @@ export async function allocateFileAnchors(
834
912
  ensureRegistry();
835
913
  const registry = current();
836
914
  const shadow = options?.shadow === true;
915
+ if (!shadow && registry) touchPath(registry, path);
837
916
  const lines = splitLines(content);
838
917
  const checksums = lines.map(lineChecksum);
839
918
  if (options?.previous?.spans) {
@@ -870,7 +949,7 @@ export async function allocateFileAnchors(
870
949
  const candidates = reuseIndex.get(checksum) ?? [];
871
950
  const taken = reuseTaken.get(checksum) ?? 0;
872
951
  reuseTaken.set(checksum, taken + 1);
873
- const anchor = taken < candidates.length ? candidates[taken]! : mintAnchor(state);
952
+ const anchor = taken < candidates.length ? candidates[taken]! : mintAnchor(state, path);
874
953
  state.everMinted.add(anchor);
875
954
  state.owned.set(anchor, { path, checksum });
876
955
  return anchor;
@@ -890,6 +969,7 @@ export async function allocateFileAnchors(
890
969
  export function adoptAnchors(path: string, entries: Map<string, string>): void {
891
970
  ensureRegistry();
892
971
  const state = current()!;
972
+ touchPath(state, path);
893
973
  let served = state.served.get(path);
894
974
  if (!served) {
895
975
  served = new Map();
@@ -967,12 +1047,17 @@ function isClaimedSidecar(sidecar: string): boolean {
967
1047
  return false;
968
1048
  }
969
1049
 
1050
+ function isExpectedAccessError(error: unknown): boolean {
1051
+ const code = errCode(error);
1052
+ return code === "EPERM" || code === "EACCES";
1053
+ }
1054
+
970
1055
  export async function gcRegistrySidecars(): Promise<void> {
971
1056
  let names: string[];
972
1057
  try {
973
1058
  names = await readdir(sessionClaimsDir());
974
1059
  } catch (error) {
975
- if (errCode(error) !== "ENOENT") console.error("Failed to list registry sidecars:", error);
1060
+ if (errCode(error) !== "ENOENT" && !isExpectedAccessError(error)) console.error("Failed to list registry sidecars:", error);
976
1061
  return;
977
1062
  }
978
1063
  for (const name of names) {
@@ -982,7 +1067,7 @@ export async function gcRegistrySidecars(): Promise<void> {
982
1067
  const tmpStat = await stat(tmpPath);
983
1068
  if (Date.now() - tmpStat.mtimeMs > 60 * 60 * 1000) await rm(tmpPath, { force: true });
984
1069
  } catch (error) {
985
- if (errCode(error) !== "ENOENT") console.error("Failed to inspect registry sidecar:", error);
1070
+ if (errCode(error) !== "ENOENT" && !isExpectedAccessError(error)) console.error("Failed to inspect registry sidecar:", error);
986
1071
  }
987
1072
  continue;
988
1073
  }
@@ -995,8 +1080,12 @@ export async function gcRegistrySidecars(): Promise<void> {
995
1080
  await stat(sessionFile);
996
1081
  } catch (error) {
997
1082
  if (errCode(error) === "ENOENT") {
998
- await rm(sidecar, { force: true });
999
- } else {
1083
+ try {
1084
+ await rm(sidecar, { force: true });
1085
+ } catch (removeError) {
1086
+ if (!isExpectedAccessError(removeError)) throw removeError;
1087
+ }
1088
+ } else if (!isExpectedAccessError(error)) {
1000
1089
  console.error("Failed to inspect registry sidecar:", error);
1001
1090
  }
1002
1091
  }
@@ -11,6 +11,7 @@ import {
11
11
  } from "./constants";
12
12
  import { normalizeAutoReadAllIgnoreEntry, type AutoReadAllMode } from "./config";
13
13
  import { serveRows } from "./served";
14
+ import { formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
14
15
  import { readNormFile, safeSnapId } from "./file-reader";
15
16
  import { resolveRgPath } from "./grep";
16
17
  import { globToRegex } from "./glob";
@@ -438,7 +439,8 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
438
439
  const coverage = `[coverage: ${completeFiles} complete]`;
439
440
  const completeNames = sections.map((section) => section.file);
440
441
  const machineList = `[files complete: ${JSON.stringify(completeNames)} omitted: ${JSON.stringify(omitted)}]`;
441
- const text = `${HEADER}\n\n${coverage}\n${machineList}\n\n${sectionTexts.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}`;
442
+ const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
443
+ const text = `${HEADER}\n\n${coverage}\n${machineList}\n\n${sectionTexts.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}${reclaimNotice !== undefined ? `\n${reclaimNotice}` : ""}`;
442
444
  return { text, files: sections.length, bytes, omitted, completeFiles };
443
445
  }
444
446