pi-hashline-edit-pro 4.4.3 → 4.4.4

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
@@ -1,12 +1,73 @@
1
1
  # pi-hashline-edit-pro
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro) [![npm downloads](https://img.shields.io/npm/dm/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro)
3
+ ![pi-hashline-edit-pro banner](https://raw.githubusercontent.com/YuGiMob/pi-hashline-edit-pro/master/assets/banner.svg)
4
4
 
5
- pi-hashline-edit-pro is an extension for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) that edits files by anchor. Every line a tool shows you is prefixed with a unique 4-character anchor, and you edit by anchor. There are no line numbers and no fuzzy matching, so an edit lands on the line you meant.
5
+ [![npm version](https://img.shields.io/npm/v/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro) [![npm downloads](https://img.shields.io/npm/dm/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro) [![Explicit Edit Benchmark](https://img.shields.io/endpoint?url=https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark/resolve/main/badges/pi-hashline-edit-pro.json&style=flat-square)](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest)
6
+
7
+ pi-hashline-edit-pro is an extension for [pi-coding-agent](https://github.com/earendil-works/pi) that edits files by anchor. Every line a tool serves gets a unique 4-character anchor, and you edit by anchor. Edits are never addressed by line number and nothing is fuzzy-matched, so an edit lands on the line you meant.
6
8
 
7
9
  It is a fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 4-character tokenizer-friendly anchors and allocation-based anchor identity.
8
10
 
9
- ## Installation
11
+ ```text
12
+ read a file; every line comes back as anchor│content:
13
+
14
+ Dafo│function hello() {
15
+ Emno│ console.log("world");
16
+ HDtm│}
17
+
18
+ replace one line by its anchor:
19
+
20
+ { "remove_from": "Emno", "remove_to": "Emno", "replacement_lines": [" console.log('hi');"] }
21
+
22
+ the result is the post-edit diff with fresh anchors, so the next edit needs no re-read.
23
+ ```
24
+
25
+ ## Contents
26
+
27
+ - [Why anchors](#why-anchors)
28
+ - [Install](#install)
29
+ - [Quickstart](#quickstart)
30
+ - [Tools](#tools)
31
+ - [read](#read)
32
+ - [replace](#replace)
33
+ - [insert](#insert)
34
+ - [anchor_grep](#anchor_grep)
35
+ - [undo_last_change](#undo_last_change)
36
+ - [Batching](#batching)
37
+ - [Auto-read](#auto-read)
38
+ - [Auto-read all](#auto-read-all)
39
+ - [Configuration](#configuration)
40
+ - [Limits](#limits)
41
+ - [Tool result details](#tool-result-details)
42
+ - [Error and warning codes](#error-and-warning-codes)
43
+ - [Troubleshooting](#troubleshooting)
44
+ - [Privacy and on-disk state](#privacy-and-on-disk-state)
45
+ - [How anchors work](#how-anchors-work)
46
+ - [Benchmark](#benchmark)
47
+ - [Development](#development)
48
+ - [Credits](#credits)
49
+ - [License](#license)
50
+
51
+ ## Why anchors
52
+
53
+ Line numbers shift when anything above them changes; fuzzy matching can silently pick a similar-looking line. Anchors avoid both problems:
54
+
55
+ | Dimension | Line numbers or fuzzy matching | Anchors (this extension) |
56
+ | --- | --- | --- |
57
+ | Address | a line number | a 4-character anchor minted for one line |
58
+ | After an edit | downstream numbers shift, so stale numbers can hit the wrong line | untouched lines keep their anchors; only changed lines get new ones |
59
+ | Wrong target | a fuzzy match may land on a similar line | an anchor resolves to exactly one file and line, or the edit is refused |
60
+ | Stale request | can apply silently | refused with `[E_STALE_ANCHOR]` or `[E_RANGE_STALE]`, with fresh anchors returned for the retry |
61
+
62
+ What "pro" adds over upstream `pi-hashline-edit`: the anchor table is built from pieces that each tokenize as one token (so an anchored row and an edit call stay cheap in tokens), and anchors are allocated per line and never derived from content, so byte-identical lines never share an anchor and an edit cannot re-target a different line.
63
+
64
+ ## Install
65
+
66
+ Prerequisites:
67
+
68
+ - [pi-coding-agent](https://github.com/earendil-works/pi) `>= 0.84.0` (`@earendil-works/pi-coding-agent`).
69
+ - Node.js 22.19 or newer, or a Bun build that ships `bun:sqlite`.
70
+ - An SQLite runtime. The extension uses `node:sqlite` on Node 22.19+ and falls back to `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`, so run pi under Node or a Bun build that ships SQLite. Without a runtime, every tool fails with `[E_STORE_UNAVAILABLE]`.
10
71
 
11
72
  ```bash
12
73
  pi install npm:pi-hashline-edit-pro
@@ -18,9 +79,21 @@ To install from a local checkout:
18
79
  pi install /path/to/pi-hashline-edit-pro
19
80
  ```
20
81
 
21
- ## Usage
82
+ ### What changes in your session
83
+
84
+ | Built-in | Effect |
85
+ | --- | --- |
86
+ | `read` | overridden, returns `anchor│content` rows |
87
+ | `edit` | disabled |
88
+ | `grep` | disabled while `anchor_grep` is enabled |
89
+ | `write` | kept; an auto-read block with fresh anchors is appended to its result |
90
+ | `bash` | untouched |
91
+
92
+ If anything of yours expects line-numbered `read` output (prompts, skills, hooks), account for the override before installing.
22
93
 
23
- Read a file. Every line comes back as `anchor│content`:
94
+ ### Verify
95
+
96
+ After install, `read` any file and confirm the rows look like this:
24
97
 
25
98
  ```text
26
99
  Dafo│function hello() {
@@ -28,17 +101,54 @@ Emno│ console.log("world");
28
101
  HDtm│}
29
102
  ```
30
103
 
31
- Replace a line by its anchor:
104
+ Then replace `Emno` and confirm the result is a post-edit diff with fresh anchors.
32
105
 
33
- ```json
34
- {
35
- "remove_from": "Emno",
36
- "remove_to": "Emno",
37
- "replacement_lines": [" console.log('hi');"]
38
- }
106
+ ### Uninstall
107
+
108
+ ```bash
109
+ pi uninstall npm:pi-hashline-edit-pro
39
110
  ```
40
111
 
41
- The result is the post-edit diff with fresh anchors, so you can keep editing without re-reading. Lines you did not touch keep their anchors. After a `write`, an auto-read block gives you the new anchors. The most recent `replace` or `insert` on a file can be reverted, even after a restart.
112
+ ## Quickstart
113
+
114
+ 1. Read a file. Every line comes back as `anchor│content`:
115
+
116
+ ```text
117
+ Dafo│function hello() {
118
+ Emno│ console.log("world");
119
+ HDtm│}
120
+ ```
121
+
122
+ 2. Replace a line by its anchor. One edit per call, fields at the top level:
123
+
124
+ ```json
125
+ {
126
+ "remove_from": "Emno",
127
+ "remove_to": "Emno",
128
+ "replacement_lines": [" console.log('hi');"]
129
+ }
130
+ ```
131
+
132
+ 3. Read the post-edit diff. `-anchor│` rows are dead anchors; `+anchor│` and ` anchor│` rows are live, so you can keep editing without re-reading:
133
+
134
+ ```text
135
+ ...
136
+ -Emno│ console.log("world");
137
+ +Qwer│ console.log('hi');
138
+ ...
139
+ ```
140
+
141
+ 4. Revert the last `replace` or `insert` on the file with `undo_last_change`, whose one argument is the path:
142
+
143
+ ```json
144
+ { "path": "src/hello.ts" }
145
+ ```
146
+
147
+ Undo is single-level and survives restarts.
148
+
149
+ Nothing commits until an edit call returns: the extension validates the request before touching the file, and refuses the edit when the file's served range changed on disk.
150
+
151
+ ## Tools
42
152
 
43
153
  The extension registers five tools: `read`, `replace`, `insert`, `anchor_grep`, and `undo_last_change`. The built-in `edit` tool is disabled. `replace` and `insert` take no `path` parameter by default: the file is resolved from the anchors' session ownership alone, so an edit can only land on the file the anchors were served for. Opt in with `/hashline-config` to require `path` in `replace` and `insert` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
44
154
 
@@ -87,21 +197,18 @@ Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
87
197
 
88
198
  Single line: use the same anchor for `remove_from` and `remove_to`. `replace_from`/`replace_to` and `from`/`to` work as aliases.
89
199
 
90
- The request is checked before any file I/O, so a bad request never touches the file.
200
+ The extension checks the request before any file I/O, so a bad request never touches the file.
201
+
202
+ Auto-fixable slips fall into two groups. Fixed silently: a reversed range, stringified array text (even with a trailing JS method call, for example `[…].map(s => s)`), and embedded newlines. Fixed with a warning: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), and diff-preview rows pasted into the replacement.
91
203
 
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│`), and diff-preview rows pasted into the replacement are reported as warnings, while a reversed range, stringified array text (even with a trailing JS method call, for example `[…].map(s => s)`), and embedded newlines are corrected silently.
93
204
  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
205
 
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`.
206
+ Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
96
207
 
97
208
  An edit that produces identical content reports `No changes made` and leaves the anchors alone.
98
209
 
99
210
  After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as a marker that keeps the row's anchor, and only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch`.
100
211
 
101
- Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run solo, one after another. A solo edit commits before its result returns; a batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N` and the batch's last call shows the combined diff, with one undo reverting the whole batch. If the batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits at turn end. A batch member accepts the same request shapes and auto-fixes as a solo call.
102
- The hashline tools are sequential in pi, so a message that contains one runs all of its tool calls one at a time in the order given; a `read` or shell `cat` issued before the edit commits can still observe the pre-commit state, so verify in the next message with the post-edit diff or a fresh `read`.
103
- Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails aborts its batch-mates with `[E_OP_ABORTED]`. A call whose anchors resolve nowhere never joins a batch: it runs solo and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, join their file's batch and abort it instead of applying partially. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
104
-
105
212
  ### insert
106
213
 
107
214
  `insert` adds lines after or before an existing line without removing anything. Like `replace`, there is no `path` parameter.
@@ -114,6 +221,12 @@ Batched calls must target disjoint ranges; overlapping ranges, or any failing ca
114
221
 
115
222
  Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
116
223
 
224
+ Example: add a line after `Emno│`:
225
+
226
+ ```json
227
+ { "anchor": "Emno", "direction": "after", "lines": [" // log the greeting"] }
228
+ ```
229
+
117
230
  The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), and line endings and BOMs survive.
118
231
 
119
232
  ### anchor_grep
@@ -149,8 +262,24 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
149
262
  - If the file was modified since the last edit, the undo is refused with `[E_UNDO_STALE]` rather than overwriting those changes, and the record is kept. Once the file matches the edited state again, `undo_last_change` succeeds.
150
263
  - If the file was deleted since the last edit, `undo_last_change` restores it from the recorded pre-edit content.
151
264
  - Missing-file cleanup never touches the undo record. The per-session prune removes snapshots and served records of files that no longer exist (both are recomputed on the next read), but the undo history survives, even when the file is temporarily absent during a branch switch.
265
+ - 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 edit, even one made by another conversation.
266
+
267
+ ## Batching
268
+
269
+ Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run on their own, one after another.
270
+
271
+ - A call outside a batch commits before its result returns.
272
+ - A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N`, and the batch's last call shows the combined diff, with one undo reverting the whole batch.
273
+ - If a batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits until the last call succeeds.
274
+ - A batch member accepts the same request shapes and auto-fixes as a standalone call.
275
+
276
+ Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails aborts its batch-mates with `[E_OP_ABORTED]`.
152
277
 
153
- ### Auto-read
278
+ A call whose anchors resolve nowhere never joins a batch: it runs on its own and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, are grouped into their file's batch and abort it instead of applying partially. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
279
+
280
+ The hashline tools are sequential in pi, so a message that contains one runs all of its tool calls one at a time in the order given; a `read` or shell `cat` issued before the edit commits can still observe the pre-commit state, so verify in the next message with the post-edit diff or a fresh `read`.
281
+
282
+ ## Auto-read
154
283
 
155
284
  Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
156
285
 
@@ -158,29 +287,19 @@ After `replace`, `insert`, and `undo_last_change`, the result shows the post-edi
158
287
 
159
288
  Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff context live in `/hashline-config` and persist across sessions. The post-edit diff shows 1 surrounding line by default; change Diff context in `/hashline-config` (0-10, needs Auto-read) to show more or fewer.
160
289
 
161
- ### Auto-read all
290
+ ## Auto-read all
162
291
 
163
292
  Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing, `on` 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 `git` uses `git ls-files` only, injecting nothing when the working directory is not a git repository. 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.
164
293
 
165
294
  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), 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, .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. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand.
166
295
 
167
- Each attached file is shown as `=== path ===` followed by its `anchor│content` rows — edit directly from the attachment with replace and insert, no read needed. Files attach whole.
168
- A coverage line after the header reports the complete count. A `[files complete: [...] omitted: [...]]` line right after it lists every attached complete file and omitted file in one place — check that line instead of scanning sections.
296
+ Each attached file is shown as `=== path ===` followed by its `anchor│content` rows. Edit directly from the attachment with replace and insert, so no `read` is needed. Files attach whole.
169
297
 
170
- The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`). Extra folders and files are ignored via `/hashline-config` as Ignore folders/files and via `config.json` as `autoReadAllIgnore` (array of folder names, file names, or globs, for example `["docs", "scratch.md", "*.test.ts"]`). A single-segment entry skips any folder or file with that exact name (case-insensitive); an entry containing glob characters (`*`, `?`, `[`, `]`, `{`, `}`) is matched as a glob against the file name, or against the whole relative path when it also contains a slash, with the same syntax as `anchor_grep`'s `glob`; any other entry with a slash (for example `"src/tmp"`) skips that path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
298
+ A coverage line after the header reports the complete count. A `[files complete: [...] omitted: [...]]` line right after it lists every attached complete file and omitted file in one place. Check that line instead of scanning sections.
171
299
 
172
- ## Tool result details
173
-
174
- All five tools return machine-readable metadata in `details` alongside the model-visible text.
175
-
176
- | Tool | `details` |
177
- | --- | --- |
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; 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
- | `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
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). |
300
+ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`). Extra folders and files are ignored via `/hashline-config` as Ignore folders/files and via `config.json` as `autoReadAllIgnore` (array of folder names, file names, or globs, for example `["docs", "scratch.md", "*.test.ts"]`). A single-segment entry skips any folder or file with that exact name (case-insensitive); an entry containing glob characters (`*`, `?`, `[`, `]`, `{`, `}`) is matched as a glob against the file name, or against the whole relative path when it also contains a slash, with the same syntax as `anchor_grep`'s `glob`; any other entry with a slash (for example `"src/tmp"`) skips that path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
182
301
 
183
- ## Settings
302
+ ## Configuration
184
303
 
185
304
  | Command | Description |
186
305
  | --- | --- |
@@ -201,35 +320,69 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
201
320
  }
202
321
  ```
203
322
 
204
- On non-Windows platforms the directory honors `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows it always uses `~/.config`.
205
-
206
- ## How anchors work
207
-
208
- Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a large stride (roughly the golden ratio of the anchor space) coprime to it, so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, or you ran `/clear-anchors`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: at most 1,353,139 lines per file, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
323
+ | Key | `/hashline-config` label | Default | Effect |
324
+ | --- | --- | --- | --- |
325
+ | `autoRead` | Auto-read | `true` | Append the auto-read block after `write` and show post-edit diffs. |
326
+ | `autoReadAll` | Auto-read all | `"off"` | Attachment mode: `"off"`, `"on"`, or `"git"`. |
327
+ | `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
328
+ | `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
329
+ | `requirePath` | Require path | `false` | `replace` and `insert` require a `path` argument that must match anchor ownership. |
330
+ | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
331
+ | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
332
+
333
+ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows the directory always uses `~/.config`, where `~` is `%USERPROFILE%`. To move the directory explicitly, see [Isolated state](#isolated-state).
334
+
335
+ ## Limits
336
+
337
+ | Limit | Value | Applies to |
338
+ | --- | --- | --- |
339
+ | Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
340
+ | Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
341
+ | Line cap | 1,353,139 lines per file | `read`, `replace`, `insert` (`[E_FILE_TOO_LARGE]`) |
342
+ | File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
343
+ | Hash window | first 500 bytes of a line | anchor identity for long lines |
344
+ | Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
345
+ | Grep matches | 100 matched lines by default (`limit`) | `anchor_grep` |
346
+ | Grep output | 2000 rows and 50KB | `anchor_grep` |
347
+ | Grep row fragment | 500 bytes | long match and context rows shown as `...` fragments |
348
+ | Auto-read all files | 500 files, 200KB per file | files larger than 200KB and files past the cap are omitted |
349
+ | Auto-read all budget | 200KB floor, 2MB ceiling | total injection size, derived from the model context window |
350
+ | Stale-range feedback | first 100 lines | rows returned with `[E_RANGE_STALE]` |
351
+ | Session anchors | 1,353,139 anchors in use | all tools; the least recently read or edited files are freed when the quota is exhausted (`[W_ANCHOR_RECLAIMED]`) |
352
+
353
+ `anchor_grep` uses the same 100MB file-size cutoff as `read`. Files over the line cap are skipped silently in directory searches.
209
354
 
210
- The table is curated for tokenizers, not for humans. Every anchor is the concatenation of two 2-character pieces that each encode as a single token, and beside the `│` separator the whole 5-character `anchor│` unit is verified to tokenize as exactly three tokens in each of eight modern open-weights tokenizers (Qwen 3.5, DeepSeek V4, Gemma 4, GLM 5.3 Flash, Tencent Hy4-preview, MiniMax M3, MiMo V2.5, Kimi K3). The shipped table is the intersection that satisfies the criterion on all of them; Nemotron 3 Ultra is the one modern tokenizer excluded. An anchor therefore costs 2 tokens on a read row and 2 in an edit call, with the `│` separator as the third. Anchors are letters only. The table is shipped as `src/hashline/anchor-table.json`.
211
-
212
- Each line also carries a content checksum. The line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm). The canonicalization keeps the checksum stable across editor-save cycles that add or remove trailing whitespace. A line over 500 bytes is hashed from its first 500 bytes.
355
+ ## Tool result details
213
356
 
214
- Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) keyed by content checksum, so resume-after-restart and cross-session edits reuse ownership instead of minting duplicates. Each session also appends an ownership log (`allocate`/`free`/`clear` events) to a sidecar file under `~/.config/pi-hashline-edit-pro/sessions/`; the fold of that log is the session's source of truth, sidecars whose session file is gone are garbage-collected at startup (never the sidecar of a session that is currently loaded in this process), and the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use.
357
+ All five tools return machine-readable metadata in `details` alongside the model-visible text.
215
358
 
216
- When a range is edited, the mapping between old and new content is computed per span: lines whose content is unchanged keep their allocated anchors, anchors of removed lines are freed, and every genuinely new line is minted a fresh anchor. Anchors are never assigned by matching content; only positional survival across an edit preserves one.
359
+ | Tool | `details` |
360
+ | --- | --- |
361
+ | `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`. |
362
+ | `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+anchor│` and ` anchor│` 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`. |
363
+ | `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
364
+ | `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). |
217
365
 
218
- Two guarantees make this safe even with duplicated content:
366
+ `snapshotId`, `firstChangedLine`, and `metrics.warnings` are the three fields external consumers most often read; `snapshotId` is a string fingerprint, `firstChangedLine` is a 1-based line number on the result file, and `metrics.warnings` counts the `[W_*]` notices in `details.warnings`.
219
367
 
220
- - An edited range never borrows an anchor from a line outside it. Lines outside the replaced range keep their anchors unconditionally, even when their content is byte-identical to lines inside the range.
221
- - "Replace X with X" doesn't rotate the anchor: a line whose content is unchanged after an edit keeps its allocated anchor positionally. Every other line is minted fresh, so an anchor is never assigned by content matching.
368
+ ## Error and warning codes
222
369
 
223
- A no-op replace never changes the file, so anchors remain valid. On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`.
370
+ Codes starting with `E_` are errors: nothing was written, with one exception. `File was written; anchor finalization failed` means the file was written and one undo reverts it. Codes starting with `W_` are warnings: the call succeeded with an auto-fix notice or an anchor-reclaim notice; check `classification` (`applied` vs `noop`) in `details.metrics` to tell whether bytes changed. `[E_AUTO_READ_ALL]` is informational rather than a failure: the `read` was refused because the file is unchanged since the start-of-session auto-read, so the attached content is still exact.
224
371
 
225
- On POSIX systems, the state directory is restricted to mode `0700` and the SQLite database plus its WAL/SHM sidecars to `0600`. The undo table contains the complete pre-edit and post-edit text for the latest edit to each file, so the store should still be treated as sensitive data.
372
+ Most common, with the fix:
226
373
 
227
- ## Error and warning codes
374
+ - `[E_STALE_ANCHOR]`: the anchor is not owned in this session. Call `read` for fresh anchors and retry.
375
+ - `[E_RANGE_STALE]`: a line in the replaced range changed on disk or was never shown. The error already returns the current range with fresh anchors; retry with those.
376
+ - `[E_FILE_TOO_LARGE]`: the file exceeds the 1,353,139-line hashline limit or the 100MB size limit. Use `write` for very large files.
377
+ - `[E_STORE_UNAVAILABLE]`: no SQLite runtime. Run pi under Node 22.19+ or a Bun build that ships `bun:sqlite`.
378
+ - `[E_WRITE_HASH_ECHO]`: a `write` content line contains a copied served row. Remove the anchors and retry.
379
+ - `[E_GREP_TIMEOUT]`: ripgrep timed out after 10 seconds. Narrow `path` or simplify `pattern` and retry.
228
380
 
229
- Codes starting with `E_` are errors: nothing was written — except `File was written; anchor finalization failed`, which means the file was written and one undo reverts it. Codes starting with `W_` are warnings: the call succeeded with an auto-fix notice; check `classification` (`applied` vs `noop`) in `details.metrics` to tell whether bytes changed.
381
+ Full reference:
230
382
 
231
383
  | Code | Meaning |
232
384
  | --- | --- |
385
+ | `[E_CONFIG]` | `PI_HASHLINE_DIR` is nonempty but not an absolute path. |
233
386
  | `[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), content contains a NUL byte (`U+0000`), which would make the file binary, or a grep `glob` has invalid bracket or brace syntax. |
234
387
  | `[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). |
235
388
  | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
@@ -237,6 +390,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
237
390
  | `[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. |
238
391
  | `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
239
392
  | `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
393
+ | `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
240
394
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
241
395
  | `[E_NOT_FOUND]` | The path does not exist. |
242
396
  | `[E_ACCESS]` | The file is not readable or writable. |
@@ -266,6 +420,66 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
266
420
  - Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
267
421
  - 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.
268
422
  - Windows drives in WSL. Editing a file under a Windows mount (`/mnt/c`, drvfs/9p) can fail with `EPERM` from `fchmod` because those filesystems do not store POSIX modes. Mode preservation is best-effort there, so `replace`, `insert`, and `undo_last_change` still write the edit.
423
+ - Not sure what the extension changed. `read` returns anchored rows and the built-in `edit` is gone; that is expected. See [What changes in your session](#what-changes-in-your-session).
424
+
425
+ ## Privacy and on-disk state
426
+
427
+ All state lives under the config directory (see [Configuration](#configuration)):
428
+
429
+ | Path | Contents |
430
+ | --- | --- |
431
+ | `config.json` | The settings from [Configuration](#configuration). |
432
+ | `hash-store.sqlite` (+ `-wal`, `-shm`) | Per-file snapshots of allocated anchors keyed by content checksum, plus the undo table. |
433
+ | `sessions/<key>.registry.jsonl` | The session's anchor ownership log (`allocate`/`free`/`clear` events). |
434
+
435
+ The undo table contains the complete pre-edit and post-edit text for the latest edit to each file, so treat the store as sensitive data. On POSIX systems the state directory is restricted to mode `0700` and the SQLite database plus its WAL/SHM sidecars to `0600`. Sidecar logs whose session file is gone are garbage-collected at startup (never the sidecar of a session that is currently loaded in this process); the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use. Served records live in memory only and are recomputed on the next read.
436
+
437
+ ### Isolated state
438
+
439
+ Set `PI_HASHLINE_DIR` before starting pi to an absolute directory to override only this extension's state directory on all platforms. An unset or empty value preserves the XDG/home defaults; a nonempty relative value is rejected with `[E_CONFIG]`. Keep the value fixed for the lifetime of the process; the database remains a process-wide singleton.
440
+
441
+ Config, SQLite (including WAL/SHM and undo history), registry sidecars, and the legacy `hash-store.json` location all follow the override. A fresh directory starts without shared config or history: no data is copied or imported from the default directory. Legacy migration, if needed, reads only `hash-store.json` inside the selected directory. Use a separate, access-restricted directory for each isolation scope; undo contains full file text.
442
+
443
+ Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` without deleting records or logging each inaccessible path. Unexpected errors remain visible; tool file-access failures and SQLite errors are not silenced.
444
+
445
+ ## How anchors work
446
+
447
+ ### Allocation
448
+
449
+ Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 26⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
450
+
451
+ ### Ownership and mapping across edits
452
+
453
+ When a range is edited, the mapping between old and new content is computed per span: lines whose content is unchanged keep their allocated anchors, anchors of removed lines are freed, and every genuinely new line is minted a fresh anchor. Anchors are never assigned by matching content; only positional survival across an edit preserves one.
454
+
455
+ Two guarantees make this safe even with duplicated content:
456
+
457
+ - An edited range never borrows an anchor from a line outside it. Lines outside the replaced range keep their anchors unconditionally, even when their content is byte-identical to lines inside the range.
458
+ - "Replace X with X" doesn't rotate the anchor: a line whose content is unchanged after an edit keeps its allocated anchor positionally. Every other line is minted fresh, so an anchor is never assigned by content matching.
459
+
460
+ A no-op replace never changes the file, so anchors remain valid. On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`.
461
+
462
+ ### The anchor table is built for tokenizers
463
+
464
+ Every anchor is the concatenation of two 2-character pieces that each encode as a single token, and beside the `│` separator the whole 5-character `anchor│` unit is verified to tokenize as exactly three tokens in each of eight modern open-weights tokenizers (Qwen 3.5, DeepSeek V4, Gemma 4, GLM 5.3 Flash, Tencent Hy4-preview, MiniMax M3, MiMo V2.5, Kimi K3). The shipped table is the intersection that satisfies the criterion on all of them; Nemotron 3 Ultra is the one modern tokenizer excluded. Model names are as published for the 4.4.x table build. An anchor therefore costs 2 tokens on a read row and 2 in an edit call, with the `│` separator as the third. Anchors are letters only. The table is shipped as `src/hashline/anchor-table.json`.
465
+
466
+ ### Line checksums
467
+
468
+ Each line also carries a content checksum. The line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm). The canonicalization keeps the checksum stable across editor-save cycles that add or remove trailing whitespace. A line over 500 bytes is hashed from its first 500 bytes.
469
+
470
+ ### Persistent state
471
+
472
+ Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) keyed by content checksum, so resume-after-restart and cross-session edits reuse ownership instead of minting duplicates. Each session also appends an ownership log (`allocate`/`free`/`clear` events) to a sidecar file under `~/.config/pi-hashline-edit-pro/sessions/`; that log is the session's record, sidecars whose session file is gone are garbage-collected at startup (never the sidecar of a session that is currently loaded in this process), and the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use.
473
+
474
+ ## Benchmark
475
+
476
+ pi-hashline-edit-pro is measured on the [Explicit Edit Benchmark](https://github.com/alexshpunt/explicit-edit-benchmark), an open third-party benchmark maintained by [alexshpunt](https://github.com/alexshpunt). The suite has 226 deterministic, byte-exact editing tasks: replacements, insertions, deletions, copies, moves, large files, several file types, and Unicode edge cases. A verifier compares the result file tree byte by byte, so nothing is graded on compilation or behavioral equivalence. Many harnesses are scored on it.
477
+
478
+ The current release scores 98.7% in the explorer's harness-family view for `pi-hashline-edit-pro`, with 99.6% or better final exactness on every model route.
479
+
480
+ First exact means the first attempt, with no recovery round, reproduced the expected bytes exactly. Final exact is the same after the benchmark's allowed recovery rounds. Quality is `0.75 × first exact + 0.25 × final exact`, so first-attempt accuracy dominates. The family rollup is the median quality across the complete, eligible model-route configurations scored with this harness; partial and quarantined configurations stay visible in the dataset but do not enter the median.
481
+
482
+ Per-model and per-version numbers, plus the spread behind the headline value, are in the [benchmark explorer](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest); raw observations and scoring rules are in the [dataset](https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark) and the [methodology](https://github.com/alexshpunt/explicit-edit-benchmark/blob/main/docs/methodology.md). Treat the score as one measurement of this suite at one point in time.
269
483
 
270
484
  ## Development
271
485
 
@@ -273,7 +487,9 @@ Requires [Node.js](https://nodejs.org) 22.19 or newer and npm.
273
487
 
274
488
  ```bash
275
489
  npm install
276
- npm test
490
+ npm test # full suite
491
+ npm run test:unit # unit suite (heavy stress and property tests excluded)
492
+ npm run test:coverage # full suite with coverage thresholds
277
493
  npm run lint
278
494
  npm run typecheck
279
495
  ```
package/index.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  setAutoReadAllIgnoreFromText,
25
25
  } from "./src/config";
26
26
  import { loadHashStore, pruneMissing } from "./src/hash-store";
27
- import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
27
+ import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
28
28
  import { serveRows } from "./src/served";
29
29
  import { finalizeTurn, planAssistantMessage } from "./src/batch";
30
30
  import { currentEditFlags } from "./src/edit-common";
@@ -217,10 +217,11 @@ export default function (pi: ExtensionAPI): void {
217
217
  );
218
218
  const fileLines = splitLines(normalized);
219
219
  serveRows(absolutePath, fileHashes, fileLines, preview.servedHashes);
220
+ const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
220
221
  return {
221
222
  content: [
222
223
  ...(event.content ?? []),
223
- { type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${preview.text}` },
224
+ { type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${preview.text}${reclaimNotice !== undefined ? `\n\n${reclaimNotice}` : ""}` },
224
225
  ],
225
226
  };
226
227
  } catch (error) {
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.4.4",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -1,2 +1,3 @@
1
1
  - `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.
@@ -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
 
package/src/batch.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  type HEdit,
17
17
  type PlannedEdit,
18
18
  } from "./hashline";
19
- import { adoptAnchors, servedForPath } from "./anchor-registry";
19
+ import { adoptAnchors, servedForPath, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
20
20
  import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
21
21
  import { assertInsertReq, assertReq, normReq } from "./payload-contract";
22
22
  import { saveUndo } from "./replace-undo";
@@ -638,6 +638,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
638
638
  if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
639
639
  throw error;
640
640
  }
641
+ const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
642
+ if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
641
643
  if (composed === base.content) {
642
644
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
643
645
  return combinedNoop(paths.displayPath, member, runtime, snapshotId);
@@ -700,6 +702,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
700
702
  const detail = error instanceof Error ? error.message : String(error);
701
703
  throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
702
704
  }
705
+ const writeReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
706
+ if (writeReclaim !== undefined) warnings.push(writeReclaim);
703
707
  const range = changedRange(base.content, composed);
704
708
  let added = 0;
705
709
  let removed = 0;
@@ -746,6 +750,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
746
750
  async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined): Promise<TResult> {
747
751
  const executed = runtime.applied + runtime.noops;
748
752
  const warnings = [...runtime.warnings];
753
+ const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
754
+ if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
749
755
  const noop = buildNoop(
750
756
  {
751
757
  path,
package/src/commit.ts CHANGED
@@ -6,6 +6,7 @@ import { saveUndo } from "./replace-undo";
6
6
  import { getDiffContextLines } from "./config";
7
7
  import { safeSnapId } from "./file-reader";
8
8
  import { writeAtomic } from "./fs-write";
9
+ import { formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
9
10
  import { servedHashesFromDiff, serveRows } from "./served";
10
11
  import { lineHashes } from "./hashline";
11
12
  import { spanForEdit } from "./replace";
@@ -27,6 +28,8 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
27
28
  const { path, absolutePath, mutationTargetPath, signal } = meta;
28
29
  const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
29
30
  const editsAttempted = 1;
31
+ const readReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
32
+ if (readReclaim !== undefined) warnings.push(readReclaim);
30
33
 
31
34
  if (pipe.result === pipe.originalNormalized) {
32
35
  const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
@@ -120,6 +123,8 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
120
123
  const detail = error instanceof Error ? error.message : String(error);
121
124
  throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
122
125
  }
126
+ const writeReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
127
+ if (writeReclaim !== undefined) warnings.push(writeReclaim);
123
128
  const successInput = {
124
129
  path,
125
130
  originalNormalized: pipe.originalNormalized,
package/src/constants.ts CHANGED
@@ -19,6 +19,9 @@ export const NUL_CONTENT_MSG =
19
19
  export const ANCHOR_POOL_EXHAUSTED_PREFIX =
20
20
  "[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
21
21
 
22
+ export const ANCHOR_RECLAIM_WARNING_CODE = "[W_ANCHOR_RECLAIMED]";
23
+ export const MAX_RECLAIMED_PATHS_REPORTED = 10;
24
+
22
25
  export const AUTO_READ_ALL_CUSTOM_TYPE = "hashline-auto-read-all";
23
26
  export const AUTO_READ_ALL_MAX_FILES = 500;
24
27
  export const AUTO_READ_ALL_MAX_FILE_BYTES = 200_000;
@@ -163,8 +163,9 @@ export async function resolveEditTargetWithRequirement(input: PathRequirementInp
163
163
  return anchorTarget;
164
164
  }
165
165
 
166
+ const AUTO_FIX_WARNING_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]"];
166
167
  export async function throwIfStrictInput(warnings: string[]): Promise<void> {
167
- const fixes = warnings.filter((warning) => warning.startsWith("[W_"));
168
+ const fixes = warnings.filter((warning) => AUTO_FIX_WARNING_CODES.some((code) => warning.startsWith(code)));
168
169
  if (fixes.length === 0) return;
169
170
  const { strictInput } = await readConfig();
170
171
  if (strictInput === true) {
package/src/grep.ts CHANGED
@@ -14,7 +14,7 @@ import { toCwd, toDisplayPath } from "./paths";
14
14
  import { loadP, loadGuide } from "./prompts";
15
15
  import { normReq } from "./payload-contract";
16
16
  import { abortIf, clipLine, errCode, gutterWidth, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
17
- import { withAnchorSession } from "./anchor-registry";
17
+ import { withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
18
18
  import { serveRows } from "./served";
19
19
  import { Text } from "@earendil-works/pi-tui";
20
20
  import { expandHint, getResultText, reuseText, type CallT, type FgT } from "./replace-render";
@@ -660,6 +660,8 @@ export function regGrep(pi: ExtensionAPI): void {
660
660
  .map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
661
661
  .join("\n");
662
662
  const notes: string[] = [];
663
+ const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
664
+ if (reclaimNotice !== undefined) notes.push(reclaimNotice);
663
665
  if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
664
666
  if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
665
667
  if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
package/src/hash-store.ts CHANGED
@@ -588,7 +588,7 @@ async function statMissing(rows: { path: string }[]): Promise<string[]> {
588
588
  } catch (error: unknown) {
589
589
  const code = errCode(error);
590
590
  if (code !== "ENOENT" && code !== "ENOTDIR") {
591
- console.error("Failed to stat hash store path:", row.path, error);
591
+ if (code !== "EPERM" && code !== "EACCES") console.error("Failed to stat hash store path:", row.path, error);
592
592
  return undefined;
593
593
  }
594
594
  return row.path;
package/src/paths.ts CHANGED
@@ -16,7 +16,11 @@ function configBase(): string {
16
16
  }
17
17
 
18
18
  export function configDir(): string {
19
- return join(configBase(), "pi-hashline-edit-pro");
19
+ const override = process.env.PI_HASHLINE_DIR;
20
+ if (override && !isAbsolute(override)) {
21
+ throw new Error("[E_CONFIG] PI_HASHLINE_DIR must be an absolute path");
22
+ }
23
+ return override || join(configBase(), "pi-hashline-edit-pro");
20
24
  }
21
25
 
22
26
  export function configPath(): string {
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, sessionKeyFor } from "./anchor-registry";
22
+ import { withAnchorSession, servedForPath, sessionKeyFor, formatAnchorReclaimNotice, takeReclaimedPaths } 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";
@@ -258,10 +258,12 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
258
258
  );
259
259
  serveRows(resolvedPath, fileHashes, fileLines, preview.servedHashes);
260
260
  const snapshotId = await safeSnapId(absolutePath, "read");
261
- const previewText =
262
- hadUtf8DecodeErrors
263
- ? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
264
- : preview.text;
261
+ const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
262
+ const previewText = [
263
+ preview.text,
264
+ hadUtf8DecodeErrors ? "[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]" : undefined,
265
+ reclaimNotice,
266
+ ].filter((part): part is string => part !== undefined).join("\n\n");
265
267
 
266
268
  return {
267
269
  content: [{ type: "text", text: previewText }],
@@ -6,7 +6,7 @@ import { Type } from "typebox";
6
6
  import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
7
7
  import { servedHashesFromDiff, serveRows } from "./served";
8
8
  import { lineChecksum } from "./hashline";
9
- import { freeAnchors, adoptAnchors, withAnchorSession } from "./anchor-registry";
9
+ import { freeAnchors, adoptAnchors, withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
10
10
  import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
11
11
  import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
12
12
  import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
@@ -237,6 +237,8 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
237
237
  parts.push(
238
238
  "Call read for fresh anchors.",
239
239
  );
240
+ const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
241
+ if (reclaimNotice !== undefined) parts.push(reclaimNotice);
240
242
 
241
243
  const patchResult = genPatch(path, currentNormalized, undo.content);
242
244
  return {
@@ -255,7 +257,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
255
257
  classification: "applied",
256
258
  editsAttempted: 1,
257
259
  noopEditsCount: 0,
258
- warningsCount: 0,
260
+ warningsCount: reclaimNotice !== undefined ? 1 : 0,
259
261
  firstChangedLine: restoredRange?.firstChangedLine,
260
262
  lastChangedLine: restoredRange?.lastChangedLine,
261
263
  addedLines: linesRemovedByReplace,