pi-hashline-edit-pro 2.7.2 → 2.8.1

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 (44) hide show
  1. package/README.md +7 -1
  2. package/index.ts +9 -6
  3. package/package.json +10 -10
  4. package/prompts/grep-guidelines.md +3 -7
  5. package/prompts/grep-snippet.md +1 -1
  6. package/prompts/grep.md +1 -1
  7. package/prompts/insert-guidelines.md +3 -7
  8. package/prompts/insert-snippet.md +1 -1
  9. package/prompts/insert.md +1 -1
  10. package/prompts/read-guidelines.md +1 -1
  11. package/prompts/read.md +1 -1
  12. package/prompts/replace-guidelines.md +5 -9
  13. package/prompts/replace-snippet.md +1 -1
  14. package/prompts/replace.md +1 -1
  15. package/prompts/undo-last-change-guidelines.md +2 -4
  16. package/prompts/undo-last-change-snippet.md +1 -1
  17. package/prompts/undo-last-change.md +1 -1
  18. package/src/commit.ts +2 -1
  19. package/src/edit-common.ts +45 -0
  20. package/src/file-kind.ts +14 -8
  21. package/src/file-reader.ts +34 -13
  22. package/src/fs-write.ts +60 -3
  23. package/src/grep.ts +119 -32
  24. package/src/hash-store/cache.ts +18 -0
  25. package/src/hash-store/retry.ts +48 -0
  26. package/src/hash-store/validation.ts +62 -0
  27. package/src/hash-store.ts +68 -133
  28. package/src/hashline/hash.ts +11 -9
  29. package/src/hashline/parse.ts +15 -1
  30. package/src/hashline/resolve.ts +3 -2
  31. package/src/insert.ts +11 -32
  32. package/src/normalize.ts +27 -0
  33. package/src/payload-contract.ts +102 -0
  34. package/src/read.ts +15 -1
  35. package/src/replace-diff.ts +28 -45
  36. package/src/replace-render.ts +18 -40
  37. package/src/replace-response.ts +1 -0
  38. package/src/replace-undo.ts +28 -13
  39. package/src/replace.ts +49 -128
  40. package/src/served.ts +26 -5
  41. package/src/utils.ts +58 -0
  42. package/src/validation.ts +2 -2
  43. package/src/write-hook.ts +59 -0
  44. package/src/replace-normalize.ts +0 -13
package/README.md CHANGED
@@ -139,7 +139,7 @@ Notes:
139
139
  | --- | --- |
140
140
  | `pattern` | Search pattern (regex, or literal text when `literal` is true). |
141
141
  | `path` | File or directory to search (default: the current working directory). |
142
- | `glob` | Filter files by glob pattern; `*` matches across directories, e.g. `*.ts` or `**/*.spec.ts`. |
142
+ | `glob` | Filter files by glob pattern; `*` matches across directories, e.g. `*.ts` or `**/*.spec.ts`. A leading `/` is ignored, and the pattern may be relative to the search root or to the current directory. |
143
143
  | `ignoreCase` | Case-insensitive search (default: false). |
144
144
  | `literal` | Treat the pattern as literal text instead of a regex (default: false). |
145
145
  | `context` | Lines of context before and after each match; context rows carry anchors too (default: 0). |
@@ -148,6 +148,7 @@ Notes:
148
148
  Notes:
149
149
  - Results are grouped per file under a `=== path ===` header; every shown row carries the anchor it would have in `read` output.
150
150
  - Directory searches skip `node_modules`, `.git`, `.tmp`, and `coverage`. Binary, image, and oversized files are skipped silently.
151
+ - Regex patterns with backreferences, nested quantifiers, quantified alternation, or multiple variable quantifiers are rejected with `[E_UNSAFE_REGEX]` before any files are scanned; use `literal: true` when regex behavior is unnecessary.
151
152
  - Output is capped at `limit` matched lines, 2000 rows, and 50KB of text (whichever comes first), with a hint naming the cap that cut results. A matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, so the relevant part of the hit stays visible; a context line over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable with `replace` (which always replaces the whole line). Directory scans stop after 4000 files with a hint; results may be incomplete.
152
153
  - `file_path` works as an alias for `path`.
153
154
  - Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
@@ -206,6 +207,8 @@ Anchors are unique by construction. If a line's base hash collides with an alrea
206
207
 
207
208
  Hashes live in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that keeps the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
208
209
 
210
+ 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.
211
+
209
212
  The store also keeps a per-file record of the hashes the model was last served (`read` rows, auto-read blocks, post-edit diff rows), pruned to the file's current hashes on every update so removed lines' hashes do not accumulate. `replace` verifies every line of the resolved range against that record before writing; a line whose hash is missing from the record means it either changed on disk after it was shown or was never shown, and the edit is refused with `[E_RANGE_STALE]`. A `write` clears the record, so edits after a write are verified against whatever the next `read` or auto-read block serves.
210
213
 
211
214
  Two guarantees make this safe even with duplicated content:
@@ -235,6 +238,9 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
235
238
  | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
236
239
  | `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
237
240
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
241
+ | `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
242
+ | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
243
+ | `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
238
244
 
239
245
  ## Troubleshooting
240
246
 
package/index.ts CHANGED
@@ -16,10 +16,10 @@ import {
16
16
  import { loadHashStore, pruneMissing } from "./src/hash-store";
17
17
  import { recordServedSafe, clearServed } from "./src/served";
18
18
  import { clearBoundaryBypass } from "./src/boundary-bypass";
19
+ import { registerWriteHook } from "./src/write-hook";
19
20
  import { readNormFile } from "./src/file-reader";
20
21
  import { loadFileKindAndText } from "./src/file-kind";
21
- import { toCwd } from "./src/paths";
22
- import { resolveTarget } from "./src/fs-write";
22
+ import { resolveInCwd } from "./src/fs-write";
23
23
  import { valAccess } from "./src/validation";
24
24
 
25
25
  export default function (pi: ExtensionAPI): void {
@@ -29,6 +29,7 @@ export default function (pi: ExtensionAPI): void {
29
29
  regInsert(pi);
30
30
  regGrep(pi);
31
31
  regUndo(pi);
32
+ registerWriteHook(pi);
32
33
 
33
34
  let autoRead = true;
34
35
 
@@ -67,7 +68,7 @@ export default function (pi: ExtensionAPI): void {
67
68
  let resolvedPath: string | undefined;
68
69
  if (typeof writtenPath === "string") {
69
70
  try {
70
- resolvedPath = await resolveTarget(toCwd(writtenPath, ctx.cwd));
71
+ resolvedPath = (await resolveInCwd(writtenPath, ctx.cwd)).resolved;
71
72
  await clearUndo(resolvedPath);
72
73
  clearBoundaryBypass(resolvedPath);
73
74
  const store = await loadHashStore();
@@ -79,7 +80,7 @@ export default function (pi: ExtensionAPI): void {
79
80
  if (!autoRead) return;
80
81
  if (typeof writtenPath !== "string") return;
81
82
  try {
82
- resolvedPath ??= await resolveTarget(toCwd(writtenPath, ctx.cwd));
83
+ resolvedPath ??= (await resolveInCwd(writtenPath, ctx.cwd)).resolved;
83
84
  await valAccess(resolvedPath, writtenPath);
84
85
  const file = await loadFileKindAndText(resolvedPath, { maxLines: MAX_HASH_LINES, displayPath: writtenPath });
85
86
  if (file.kind !== "text") return;
@@ -124,7 +125,8 @@ export default function (pi: ExtensionAPI): void {
124
125
  if (metrics?.classification === "noop") return;
125
126
 
126
127
  const diff = (event.details as { diff?: string } | undefined)?.diff;
127
- if (!diff) return;
128
+ if (typeof diff !== "string") return;
129
+ const hasDiff = diff.length > 0;
128
130
 
129
131
  const rendered = (event.content ?? [])
130
132
  .filter(
@@ -134,11 +136,12 @@ export default function (pi: ExtensionAPI): void {
134
136
  .map((entry) => entry.text)
135
137
  .join("\n");
136
138
  const warnings = extractWarnings(rendered);
139
+ const hint = hasDiff ? (warnings ? `${diff}\n\n${warnings}` : diff) : warnings ? `[post-edit] applied successfully; the diff is empty (whitespace-only change).\n\n${warnings}` : "[post-edit] applied successfully; the diff is empty (whitespace-only change).";
137
140
  return {
138
141
  content: [
139
142
  {
140
143
  type: "text",
141
- text: warnings ? `${diff}\n\n${warnings}` : diff,
144
+ text: hint,
142
145
  },
143
146
  ],
144
147
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.7.2",
3
+ "version": "2.8.1",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -39,13 +39,13 @@
39
39
  ]
40
40
  },
41
41
  "dependencies": {
42
- "diff": "^8.0.2",
43
- "file-type": "^21.3.0",
42
+ "diff": "^9.0.0",
43
+ "file-type": "^22.0.2",
44
44
  "typebox": "^1.3.7",
45
45
  "xxhash-wasm": "^1.1.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@earendil-works/pi-coding-agent": ">=0.75.0",
48
+ "@earendil-works/pi-coding-agent": ">=0.84.0",
49
49
  "@earendil-works/pi-tui": "*"
50
50
  },
51
51
  "engines": {
@@ -63,12 +63,12 @@
63
63
  "devDependencies": {
64
64
  "@earendil-works/pi-coding-agent": "^0.84.0",
65
65
  "@eslint/js": "^10.0.1",
66
- "@types/node": "^24.0.0",
67
- "@vitest/coverage-v8": "^4.1.10",
68
- "eslint": "^10.7.0",
69
- "typescript": "^5.8.0",
70
- "typescript-eslint": "^8.65.0",
71
- "vitest": "^4.1.8"
66
+ "@types/node": "^24",
67
+ "@vitest/coverage-v8": "^4.1.11",
68
+ "eslint": "^10.9.1",
69
+ "typescript": "^5.9.3",
70
+ "typescript-eslint": "^8.68.0",
71
+ "vitest": "^4.1.11"
72
72
  },
73
73
  "allowScripts": {
74
74
  "@google/genai@1.52.0": true,
@@ -1,7 +1,3 @@
1
- - `grep`: results carry anchors recorded like read output, so you can target them with replace or insert immediately.
2
- - `grep`: pass `path` for a single file or directory; the default is the current working directory. Directory searches skip node_modules, .git, .tmp, and coverage.
3
- - `grep`: use `literal: true` when the pattern contains regex metacharacters you want matched literally.
4
- - `grep`: use `context` to see surrounding lines; context rows carry anchors too.
5
- - `grep`: use `glob` to filter files; `*` matches across directories, e.g. `*.ts` or `**/*.spec.ts`.
6
- - `grep`: results are capped at `limit` matches (default 100), 2000 rows, and 50KB; refine the pattern or raise limit to see more.
7
- - `grep`: a matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides; the row keeps its anchor and is editable with replace (which replaces the whole line). Use read to see the full line.
1
+ - `grep`: every hit and `context` line comes back as `anchor│content` use that anchor directly for `replace`/`insert` without a new `read`.
2
+ - `grep`: use `path` for file or folder (default cwd), `glob` like `*.ts` to filter, `literal:true` for literal text, `context:N` for surrounding lines.
3
+ - `grep`: search skips `node_modules/.git/.tmp/coverage` and skips binary/image files.
@@ -1 +1 @@
1
- Search file contents; matching lines carry anchors usable in replace/insert without a re-read
1
+ Search with `anchor│content` hits usable directly for `replace`/`insert`; use `literal`/`glob`/`context`
package/prompts/grep.md CHANGED
@@ -1 +1 @@
1
- Search text files for a pattern. Returns every matching line (and the requested context lines) as `anchor│content` rows, so the results can be used directly as replace and insert anchors without a separate read. Directory searches skip node_modules, .git, .tmp, and coverage. Binary, image, and oversized files are skipped silently. Output is capped at `limit` matches (default 100), 2000 rows, and 50KB; a matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, and the row keeps its anchor (long lines are hashed from their first 500 bytes), so the match is still editable.
1
+ Search text files for a pattern. Every hit and each `context` line is returned as `anchor│content` so you can use that anchor directly for `replace`/`insert` without a new `read`. Directory search skips `node_modules/.git/.tmp/coverage` and skips binary/image files. Matching lines longer than 500 bytes are shown as a fragment around the match with `...`, but the anchor is still valid for the whole line. If output says truncated, refine `pattern` or raise `limit` as hinted.
@@ -1,7 +1,3 @@
1
- - `insert`: anchor takes ONLY the bare 3-char anchor of the line next to which the new lines go: read row `ve7│function hello() {` means `"anchor": "ve7"`. Never paste the line content or the whole `anchor│content` row.
2
- - `insert`: the anchor line is preserved. Include only the new lines in `lines`, never the anchor line itself.
3
- - `insert`: use `direction: "after"` to add lines after the anchor line, `"before"` to add them before it.
4
- - `insert`: read the file first, so the anchor line was shown to you. Use a post-edit diff row or grep output for follow-up inserts.
5
- - `insert`: to seed an empty file, read it and insert after the `anchor│` empty-line row.
6
- - `insert`: lines are applied literally — never deduplicated — so restating a neighbor is safe and has no effect on the result.
7
- - `insert`: the post-edit diff is capped at 50KB; a row longer than 50KB is shown as a marker that keeps the row's anchor, and the diff ends with a truncation note when the cap is hit, so call read for content the diff did not show.
1
+ - `insert`: `anchor` is bare `aB3` from `aB3│content` (never the content), direction `after` adds below, `before` adds above. Do not put the anchor line in `lines`; `[""]` is a blank line.
2
+ - `insert`: `lines` is bare content, one element per line, kept literally even if it duplicates neighbors nothing is removed.
3
+ - `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or `grep`. For an empty file, `read` shows one `anchor│` empty row — insert `after` it.
@@ -1 +1 @@
1
- Insert lines after or before a line in a text file via bare 3-char anchor from read: the anchor line stays, new lines go after/before it, applied literally
1
+ Insert `lines` after/before bare anchor `aB3` from `aB3│content`: anchor stays, lines are bare without `│`, one per element
package/prompts/insert.md CHANGED
@@ -1 +1 @@
1
- Insert lines after or before an existing line in a text file, targeted by 3-character anchors from read output. The anchor line is preserved; the new lines are added after it (direction "after") or before it (direction "before"). Each element of lines is exactly one line; do not embed \n inside an element: use separate elements. Use [""] for a blank line. The lines you send are added literally: nothing is removed, and lines that duplicate their neighbors are kept. The post-edit diff is capped at 50KB: rows longer than 50KB are shown as markers that keep the row's anchor, and the diff ends with a truncation note when the cap is hit; call read for anything not shown.
1
+ Insert lines after or before one existing line in a text file, using a bare anchor like `aB3` from `aB3│content`. The anchor line stays; your `lines` are added after (`"after"`) or before (`"before"`) it, one string per line. Use `[""]` for a blank line, never put `\n` inside an element and never include the anchor line in `lines`. Lines are added literally, even if they duplicate neighbors.
@@ -1,2 +1,2 @@
1
1
  - `read`: call before `replace` when you need fresh anchors for a file.
2
- - `read`: call again after an edit when you need anchors you do not have. The post-edit diff after replace/insert and the anchored rows after grep already carry fresh anchors for the changed range.
2
+ - `read`: call again after an edit when you need anchors you do not have post-edit diff `+anchor│`/` anchor│` rows and `grep` hits are already fresh anchors for the changed range, so no new `read` needed for those lines.
package/prompts/read.md CHANGED
@@ -1 +1 @@
1
- Read a text file; each line returned as `anchor│content` with a 3-character alphanumeric anchor. No line numbers: use the anchor in replace and insert calls. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → anchor│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
1
+ Read a text file; each line returned as `anchor│content` with a 3-character alphanumeric anchor (e.g. `aB3│hello` is anchor `aB3` plus content `hello`). No line numbers: use the anchor in replace and insert calls. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → anchor│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD. If output says truncated, use `offset`/`limit` as hinted.
@@ -1,9 +1,5 @@
1
- - `replace`: remove_from and remove_to take ONLY the bare 3-char anchor: read row `ve7│function hello() {` means `"remove_from": "ve7"`. Never paste the line content, a code line, a paragraph, or the whole `anchor│content` row into these fields.
2
- - `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and replacement_lines is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in replacement_lines is deleted from the file.
3
- - `replace`: keep the range as tight as the change: anchor only the first and last line that actually change, never a whole function, class, or import block when only part of it changes.
4
- - `replace`: to replace a single line, use the same anchor for both remove_from and remove_to (e.g. remove_from: "<ANCHOR>", remove_to: "<ANCHOR>").
5
- - `replace`: when copying a line from read output, remove its `anchor│` prefix and keep the leading whitespace exactly as shown.
6
- - `replace`: replacement_lines is an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element: each element is exactly one line.
7
- - `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file: `+anchor│` and ` anchor│` rows carry current anchors and unchanged lines keep their previous anchors, so you can anchor follow-up edits on the diff without re-reading.
8
- - `replace`: do not issue multiple replace calls on the same file in one message. Issue the next edit only after verifying the previous diff.
9
- - `replace`: the post-edit diff is capped at 50KB; a row longer than 50KB is shown as a marker that keeps the row's anchor, and the diff ends with a truncation note when the cap is hit, so call read for content the diff did not show.
1
+ - `replace`: `remove_from` and `remove_to` are bare anchors like `aB3` (the 3 chars before `│` in `aB3│content`), never the full row or file content. Use the same anchor for both to change one line.
2
+ - `replace`: `replacement_lines` is bare content without `│`, one element per line. Use `[]` to delete, `[""]` for one blank line, never put `\n` inside an element and never include the `anchor│` prefix.
3
+ - `replace`: the range from `remove_from` to `remove_to` is exactly deleted and replaced in order make it tight, only the lines that actually change, and copy leading spaces exactly.
4
+ - `replace`: diff markers like `+aB3│` or `-aB3│` are stripped automatically if pasted, but always try to send bare content.
5
+ - `replace`: `+anchor│` and ` anchor│` rows in a post-edit diff are fresh anchors you can use them for the next edit without a new `read`. Do one `replace`/`insert` per turn and check the diff before the next edit.
@@ -1 +1 @@
1
- Replace lines in a text file via bare 3-char anchors from read: anchor only, never line content; anchor exactly the lines that change; one edit per tool call
1
+ Replace lines via bare 3-char anchor `aB3` from `aB3│content`: `remove_from`/`remove_to` are `aB3` only, `replacement_lines` is bare lines without `│`; one edit per call
@@ -1 +1 @@
1
- Replace a range of lines (or a single line) in a text file, targeted by 3-character anchors from read output. remove_from and remove_to must each be a BARE anchor: copy only the anchor from the leftmost column of a read row (row `ve7│function hello() {` means `"remove_from": "ve7"`). Never pass the line content, a code line, or a paragraph into these fields. The post-edit diff is capped at 50KB: rows longer than 50KB are shown as markers that keep the row's anchor, and the diff ends with a truncation note when the cap is hit; call read for anything not shown.
1
+ Replace a range of lines (or a single line) in a text file, targeted by 3-character anchors from read output. Saw `aB3│content` in read/diff/grep: `aB3` is the bare HASH before `│`, `aB3│content` is the full row. `remove_from` and `remove_to` are bare HASH only (e.g. `"aB3"`), never the full row or file content. `replacement_lines` is bare content without `│`, one string per line use `[]` to delete. Example: read showed `aB3│old` and `kQm│old2`, to replace both use `{"remove_from":"aB3","remove_to":"kQm","replacement_lines":["new line 1","new line 2"]}`. Single line same anchor for both.
@@ -1,4 +1,2 @@
1
- - `undo_last_change`: reverts only the most recent replace or insert on the file: any write to the file clears the undo history, so call it immediately after a bad edit. An edit is bad when its post-edit diff shows `-anchor│` rows for lines you meant to keep (a closing brace, import, or declaration).
2
- - `undo_last_change`: when auto-read shows the post-edit diff, its `+anchor│` and ` anchor│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading.
3
- - `undo_last_change`: if the file was deleted since the edit, the undo restores it from the recorded pre-edit content; if the file was modified since the edit, the undo is refused with `[E_UNDO_STALE]` and the record is kept, so reverting the external change makes the undo succeed.
4
- - `undo_last_change`: the undo diff is capped at 50KB; a row longer than 50KB is shown as a marker that keeps the row's anchor, and the diff ends with a truncation note when the cap is hit, so call read for content the diff did not show.
1
+ - `undo_last_change`: only the last `replace`/`insert` per file can be undone; a successful `write` clears it, so call immediately after a bad diff (look for `-anchor│` lines you wanted to keep).
2
+ - `undo_last_change`: if the file changed after the edit you get `[E_UNDO_STALE]` the record is kept, so undo again after the file matches the edited state; if the file was deleted it is restored.
@@ -1 +1 @@
1
- Undo the last change (replace or insert) on a file
1
+ Undo last `replace`/`insert` on a file; restores deleted file, keeps record on `[E_UNDO_STALE]`
@@ -1 +1 @@
1
- Undo the last change (replace or insert) on a file, reverting it to its previous state. Use when an edit produced incorrect results (e.g., wrong content, duplicated lines, broken syntax). If the file was deleted since the edit, the undo restores it from the recorded content. If the file was modified since the edit, the undo is refused and the record is kept until the file matches the edited state again. The undo diff is capped at 50KB: rows longer than 50KB are shown as markers that keep the row's anchor, and the diff ends with a truncation note when the cap is hit; call read for anything not shown.
1
+ Undo the last `replace` or `insert` on a file, restoring previous content, BOM and line endings. Use after a bad edit when the diff showed wrong lines removed. If the file was deleted, it is restored; if changed elsewhere, you get `[E_UNDO_STALE]` and the record is kept so you can retry after restoring the edited state. If output says truncated, use `read` to see full file.
package/src/commit.ts CHANGED
@@ -5,7 +5,7 @@ import { saveUndo } from "./replace-undo";
5
5
  import { safeSnapId } from "./file-reader";
6
6
  import { writeAtomic } from "./fs-write";
7
7
  import { recordServedDiffSafe } from "./served";
8
- import { restoreEndings } from "./replace-diff";
8
+ import { restoreEndings } from "./normalize";
9
9
 
10
10
  export interface CommitMeta {
11
11
  path: string;
@@ -72,6 +72,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
72
72
  await writeAtomic(
73
73
  absolutePath,
74
74
  pipe.bom + restoreEndings(pipe.result, pipe.originalEnding),
75
+ pipe.identity,
75
76
  );
76
77
  } catch (error) {
77
78
  await undo.restore();
@@ -0,0 +1,45 @@
1
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2
+ import { resolveInCwd } from "./fs-write";
3
+ import { abortIf, makePrepareArguments } from "./utils";
4
+ import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
5
+ import type { ReplaceDetails } from "./replace";
6
+
7
+ export const editPrepare = makePrepareArguments();
8
+
9
+ export function editRenderResultWrapper(
10
+ result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
11
+ opts: { isPartial: boolean; expanded?: boolean } | boolean,
12
+ theme: FgT,
13
+ context: any,
14
+ ) {
15
+ return renderEditResult(result, opts, theme, context);
16
+ }
17
+
18
+ export function editRenderCallWrapper(
19
+ preview: (args: unknown, cwd: string) => Promise<RPreview>,
20
+ getInput?: (args: unknown) => { path?: string } | null,
21
+ toolName?: string,
22
+ ) {
23
+ return makeRenderCall(preview, { getInput, toolName });
24
+ }
25
+
26
+ export const editToolBase = {
27
+ prepareArguments: editPrepare,
28
+ executionMode: "sequential" as const,
29
+ renderShell: "default" as const,
30
+ };
31
+
32
+ export async function queuedEdit<T>(
33
+ path: string,
34
+ cwd: string,
35
+ signal: AbortSignal | undefined,
36
+ work: (absolute: string, resolved: string) => Promise<T>,
37
+ ): Promise<T> {
38
+ abortIf(signal);
39
+ const { absolute, resolved } = await resolveInCwd(path, cwd);
40
+ return withFileMutationQueue(resolved, async () => {
41
+ abortIf(signal);
42
+ return work(absolute, resolved);
43
+ });
44
+ }
45
+
package/src/file-kind.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { open as fsOpen, stat as fsStat } from "fs/promises";
2
2
  import { fileTypeFromBuffer } from "file-type";
3
3
  import { SNIFF_BYTES, MAX_BYTES } from "./constants";
4
+ import { assertLineLimit, lineLimitMoreThanMessage } from "./utils";
5
+ import type { FileIdentity } from "./fs-write";
4
6
 
5
7
  const IMG_TYPES = new Set<string>([
6
8
  "image/bmp",
@@ -53,7 +55,7 @@ function looksLikeText(sample: Uint8Array): boolean {
53
55
  export type LFile =
54
56
  | { kind: "directory" }
55
57
  | { kind: "image"; mimeType: string }
56
- | { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
58
+ | { kind: "text"; text: string; identity?: FileIdentity; hadUtf8DecodeErrors?: true }
57
59
  | { kind: "binary"; description: string }
58
60
  | { kind: "too_large"; description: string };
59
61
 
@@ -86,6 +88,8 @@ export async function loadFileKindAndText(
86
88
 
87
89
  const fileHandle = await fsOpen(filePath, "r");
88
90
  try {
91
+ const openedStats = await fileHandle.stat();
92
+ const identity = { dev: openedStats.dev, ino: openedStats.ino };
89
93
  const buffer = Buffer.alloc(SNIFF_BYTES);
90
94
  const { bytesRead } = await fileHandle.read(
91
95
  buffer,
@@ -94,7 +98,7 @@ export async function loadFileKindAndText(
94
98
  0,
95
99
  );
96
100
  if (bytesRead === 0) {
97
- return { kind: "text", text: "" };
101
+ return { kind: "text", text: "", identity };
98
102
  }
99
103
 
100
104
  const sample = buffer.subarray(0, bytesRead);
@@ -141,11 +145,7 @@ export async function loadFileKindAndText(
141
145
  for (let i = 0; i < decoded.length; i++) {
142
146
  if (decoded.charCodeAt(i) === 10) newlineCount++;
143
147
  }
144
- if (newlineCount > options.maxLines) {
145
- throw new Error(
146
- `[E_FILE_TOO_LARGE] ${options.displayPath ?? filePath} has more than ${options.maxLines} lines, exceeding the ${options.maxLines}-line hashline limit. For very large files, use write.`,
147
- );
148
- }
148
+ if (newlineCount > options.maxLines) throw new Error(lineLimitMoreThanMessage(options.displayPath ?? filePath, options.maxLines));
149
149
  }
150
150
  return decoded;
151
151
  }
@@ -169,6 +169,11 @@ export async function loadFileKindAndText(
169
169
  position += chunkBytesRead;
170
170
  }
171
171
  parts.push(decodeChunk(new Uint8Array(0), false));
172
+ const text = parts.join("");
173
+ if (options?.maxLines !== undefined && text.length > 0) {
174
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
175
+ assertLineLimit(normalized, options.displayPath ?? filePath, options.maxLines);
176
+ }
172
177
 
173
178
  if (containsNul) {
174
179
  return { kind: "binary", description: "contains NUL bytes" };
@@ -176,7 +181,8 @@ export async function loadFileKindAndText(
176
181
 
177
182
  return {
178
183
  kind: "text",
179
- text: parts.join(""),
184
+ text,
185
+ identity,
180
186
  ...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
181
187
  };
182
188
  } finally {
@@ -1,13 +1,13 @@
1
1
  import { constants } from "fs";
2
2
  import { stat } from "fs/promises";
3
+ import { relative } from "path";
3
4
  import { lineHashes } from "./hashline";
4
5
  import { loadFileKindAndText, type LFile } from "./file-kind";
5
- import { resolveTarget } from "./fs-write";
6
+ import { resolveTarget, type FileIdentity } from "./fs-write";
6
7
  import { toCwd } from "./paths";
7
- import { detectEnding, toLF, stripBOM, type LineEnding } from "./replace-diff";
8
- import { abortIf } from "./utils";
8
+ import { detectEnding, toLF, stripBOM, type LineEnding } from "./normalize";
9
+ import { abortIf, errCode, assertLineLimit } from "./utils";
9
10
  import { valKind, valAccess } from "./validation";
10
- import { visLines } from "./utils";
11
11
  import type { HashStore } from "./hash-store";
12
12
  export interface NormFile {
13
13
  absolutePath: string;
@@ -16,6 +16,7 @@ export interface NormFile {
16
16
  originalEnding: LineEnding;
17
17
  fileHashes: string[];
18
18
  hadUtf8DecodeErrors: boolean;
19
+ identity: FileIdentity;
19
20
  }
20
21
 
21
22
  export type SnapInfo = {
@@ -52,7 +53,7 @@ export async function safeSnapId(
52
53
  try {
53
54
  return (await fileSnap(absolutePath)).snapshotId;
54
55
  } catch (error) {
55
- console.error(`Failed to compute snapshot (${context}):`, error);
56
+ console.error(`[safeSnapId] ${context}: failed to stat "${absolutePath}" (code=${errCode(error) ?? "?"}):`, error);
56
57
  return undefined;
57
58
  }
58
59
  }
@@ -91,22 +92,42 @@ export async function readNormFile(
91
92
  const originalEnding = detectEnding(rawContent);
92
93
  const normalized = toLF(rawContent);
93
94
 
94
- if (options?.maxLines !== undefined) {
95
- const lineCount = visLines(normalized).length;
96
- if (lineCount > options.maxLines) {
97
- throw new Error(
98
- `[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${options.maxLines}-line hashline limit. For very large files, use write.`,
99
- );
100
- }
101
- }
95
+ if (options?.maxLines !== undefined) assertLineLimit(normalized, path, options.maxLines);
102
96
 
103
97
  const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store, options?.noPersist !== true);
98
+ let identity = file.identity;
99
+ if (!identity) {
100
+ const { dev, ino } = await stat(resolvedPath);
101
+ identity = { dev, ino };
102
+ }
104
103
  return {
105
104
  absolutePath: resolvedPath,
106
105
  normalized,
107
106
  bom,
108
107
  originalEnding,
109
108
  fileHashes,
109
+ identity,
110
110
  hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
111
111
  };
112
112
  }
113
+
114
+ export async function tryReadNormFile(
115
+ absPath: string,
116
+ cwd: string,
117
+ options?: ReadNormOptions,
118
+ ): Promise<NormFile | undefined> {
119
+ try {
120
+ const displayPath = relative(cwd, absPath).replace(/\\/g, "/") || absPath;
121
+ const file = await loadFileKindAndText(absPath, { maxLines: options?.maxLines, displayPath });
122
+ if (file.kind !== "text") return undefined;
123
+ return await readNormFile(absPath, cwd, { ...options, preloadedFile: file });
124
+ } catch (error) {
125
+ const code = errCode(error);
126
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT" || code === "ELOOP") return undefined;
127
+ if (error instanceof Error) {
128
+ const msg = error.message;
129
+ if (msg.startsWith("[E_FILE_TOO_LARGE]") || msg.startsWith("[E_NOT_FOUND]") || msg.startsWith("[E_ACCESS]") || msg.startsWith("[E_NOT_TEXT]")) return undefined;
130
+ }
131
+ throw error;
132
+ }
133
+ }
package/src/fs-write.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "crypto";
2
+ import { constants } from "fs";
2
3
  import {
3
4
  lstat,
4
5
  mkdir,
@@ -11,8 +12,25 @@ import {
11
12
  writeFile,
12
13
  } from "fs/promises";
13
14
  import { dirname, join, parse, resolve, sep } from "path";
15
+ import { toCwd } from "./paths";
14
16
  import { errCode } from "./utils";
15
17
 
18
+ export interface FileIdentity {
19
+ dev: number;
20
+ ino: number;
21
+ }
22
+
23
+ function sameIdentity(
24
+ actual: Pick<Awaited<ReturnType<typeof stat>>, "dev" | "ino">,
25
+ expected: FileIdentity,
26
+ ): boolean {
27
+ return actual.dev === expected.dev && actual.ino === expected.ino;
28
+ }
29
+
30
+ function pathChanged(path: string): Error {
31
+ return new Error(`[E_PATH_CHANGED] Refusing to write ${path}: the target changed after it was read.`);
32
+ }
33
+
16
34
  export async function resolveTarget(path: string): Promise<string> {
17
35
  const absolutePath = resolve(path);
18
36
  const { root } = parse(absolutePath);
@@ -25,7 +43,15 @@ export async function resolveTarget(path: string): Promise<string> {
25
43
  async function resParts(
26
44
  currentPath: string,
27
45
  remainingParts: string[],
46
+ symlinkDepth = 0,
28
47
  ): Promise<string> {
48
+ if (symlinkDepth > 40) {
49
+ const error = new Error(
50
+ `Too many symbolic links while resolving ${path}`,
51
+ ) as NodeJS.ErrnoException;
52
+ error.code = "ELOOP";
53
+ throw error;
54
+ }
29
55
  if (remainingParts.length === 0) {
30
56
  return currentPath;
31
57
  }
@@ -36,7 +62,7 @@ export async function resolveTarget(path: string): Promise<string> {
36
62
  try {
37
63
  const candidateStats = await lstat(candidatePath);
38
64
  if (!candidateStats.isSymbolicLink()) {
39
- return resParts(candidatePath, tail);
65
+ return resParts(candidatePath, tail, symlinkDepth);
40
66
  }
41
67
 
42
68
  if (visitedSymlinks.has(candidatePath)) {
@@ -59,7 +85,7 @@ export async function resolveTarget(path: string): Promise<string> {
59
85
  return resParts(parse(linkTargetPath).root, [
60
86
  ...targetParts,
61
87
  ...tail,
62
- ]);
88
+ ], symlinkDepth + 1);
63
89
  } catch (error: unknown) {
64
90
  if (errCode(error) === "ENOENT") {
65
91
  return join(candidatePath, ...tail);
@@ -110,9 +136,15 @@ async function syncDir(dir: string): Promise<void> {
110
136
  }
111
137
  }
112
138
 
139
+ export async function resolveInCwd(path: string, cwd: string): Promise<{ absolute: string; resolved: string }> {
140
+ const absolute = toCwd(path, cwd);
141
+ const resolved = await resolveTarget(absolute);
142
+ return { absolute, resolved };
143
+ }
113
144
  export async function writeAtomic(
114
145
  path: string,
115
146
  content: string,
147
+ expectedIdentity?: FileIdentity,
116
148
  ): Promise<void> {
117
149
  const targetPath = await resolveTarget(path);
118
150
 
@@ -125,8 +157,25 @@ export async function writeAtomic(
125
157
  }
126
158
  }
127
159
 
160
+ if (expectedIdentity && (!existingStats || !sameIdentity(existingStats, expectedIdentity))) {
161
+ throw pathChanged(path);
162
+ }
163
+
128
164
  if (existingStats && existingStats.nlink > 1) {
129
- await writeFile(targetPath, content, "utf-8");
165
+ const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
166
+ const handle = await open(targetPath, constants.O_WRONLY | noFollow);
167
+ try {
168
+ const openedStats = await handle.stat();
169
+ if (!sameIdentity(openedStats, existingStats)) throw pathChanged(path);
170
+ await handle.writeFile(content, "utf-8");
171
+ await handle.truncate(Buffer.byteLength(content, "utf-8"));
172
+ try {
173
+ await handle.chmod(existingStats.mode & 0o7777);
174
+ } catch {}
175
+ await handle.sync();
176
+ } finally {
177
+ await handle.close();
178
+ }
130
179
  return;
131
180
  }
132
181
 
@@ -148,6 +197,14 @@ export async function writeAtomic(
148
197
  }
149
198
  try {
150
199
  await tempHandle.close();
200
+ try {
201
+ const finalStats = await lstat(targetPath);
202
+ if (!existingStats || finalStats.isSymbolicLink() || !sameIdentity(finalStats, existingStats)) {
203
+ throw pathChanged(path);
204
+ }
205
+ } catch (error) {
206
+ if (errCode(error) !== "ENOENT" || existingStats) throw error;
207
+ }
151
208
  await rename(tempPath, targetPath);
152
209
  await syncDir(dir);
153
210
  } catch (error: unknown) {