pi-hashline-edit-pro 2.8.0 → 2.8.2

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
@@ -133,7 +133,7 @@ Notes:
133
133
 
134
134
  ## The grep tool
135
135
 
136
- `grep` replaces the built-in grep with an anchored search. Every matching line (and each requested context line) is returned as an `anchor│content` row, and those rows are recorded in the served state exactly like `read` output, so you can target them with `replace` or `insert` immediately without a separate `read`.
136
+ `grep` replaces the built-in grep with an anchored search backed by ripgrep. Every matching line (and each requested context line) is returned as `lineNumber anchor│content` the `anchor│content` part is served exactly like `read` output, so you can target it with `replace`/`insert` without a separate `read`, while the line-number gutter and `=== path ===` header give filename and line for navigation (press Return to jump).
137
137
 
138
138
  | Field | Description |
139
139
  | --- | --- |
@@ -146,9 +146,10 @@ Notes:
146
146
  | `limit` | Maximum number of matched lines to return (default: 100). |
147
147
 
148
148
  Notes:
149
- - Results are grouped per file under a `=== path ===` header; every shown row carries the anchor it would have in `read` output.
150
- - Directory searches skip `node_modules`, `.git`, `.tmp`, and `coverage`. Binary, image, and oversized files are skipped silently.
151
- - 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.
149
+ - Results are grouped per file under a `=== path ===` header; every shown row is `lineNumber │ anchor│content` where `anchor│content` is the anchor it would have in `read` output.
150
+ - Directory searches use ripgrep, respecting `.gitignore`; `node_modules`, `.git`, `.tmp`, and `coverage` are always skipped. 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.
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).
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.
154
155
  - Files with multiple hard links (`nlink > 1`) are rewritten in place rather than via a temp-file rename, so every link keeps seeing the same content; that write is direct rather than atomic.
@@ -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:
@@ -236,6 +239,8 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
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. |
238
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`. |
239
244
 
240
245
  ## Troubleshooting
241
246
 
package/index.ts CHANGED
@@ -14,13 +14,14 @@ import {
14
14
  toggleAutoRead,
15
15
  } from "./src/config";
16
16
  import { loadHashStore, pruneMissing } from "./src/hash-store";
17
- import { recordServedSafe, clearServed } from "./src/served";
17
+ import { recordServedSafe, clearServed, buildServedMap } from "./src/served";
18
18
  import { clearBoundaryBypass } from "./src/boundary-bypass";
19
19
  import { registerWriteHook } from "./src/write-hook";
20
20
  import { readNormFile } from "./src/file-reader";
21
21
  import { loadFileKindAndText } from "./src/file-kind";
22
22
  import { resolveInCwd } from "./src/fs-write";
23
23
  import { valAccess } from "./src/validation";
24
+ import { splitLines } from "./src/utils";
24
25
 
25
26
  export default function (pi: ExtensionAPI): void {
26
27
  regRead(pi);
@@ -37,12 +38,15 @@ export default function (pi: ExtensionAPI): void {
37
38
  const active = pi.getActiveTools();
38
39
  pi.setActiveTools(active.filter((t) => t !== "edit"));
39
40
  await initHasher();
40
- try {
41
- const store = await loadHashStore();
42
- await pruneMissing(store);
43
- } catch (err) {
44
- console.error("Failed to load or prune hash store:", err);
45
- }
41
+ loadHashStore()
42
+ .then(store =>
43
+ pruneMissing(store).catch(err => {
44
+ console.error("Failed to prune hash store:", err);
45
+ }),
46
+ )
47
+ .catch(err => {
48
+ console.error("Failed to load hash store:", err);
49
+ });
46
50
  const config = await readConfig();
47
51
  autoRead = config.autoRead;
48
52
  const debugValue = process.env.PI_HASHLINE_DEBUG;
@@ -95,7 +99,9 @@ export default function (pi: ExtensionAPI): void {
95
99
  DEFAULT_MAX_BYTES,
96
100
  DEFAULT_MAX_LINES,
97
101
  );
98
- await recordServedSafe(absolutePath, preview.servedHashes, "auto-read", new Set(fileHashes));
102
+ const fileLines = splitLines(normalized);
103
+ const servedMap = buildServedMap(fileHashes, fileLines, preview.servedHashes);
104
+ await recordServedSafe(absolutePath, servedMap, "auto-read", new Set(fileHashes));
99
105
  return {
100
106
  content: [
101
107
  ...(event.content ?? []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.8.0",
3
+ "version": "2.8.2",
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,8 +39,8 @@
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
  },
@@ -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,3 +1,3 @@
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
+ - `grep`: every hit and `context` line comes back as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` enables jump-to-line.
2
+ - `grep`: uses ripgrep, respects `.gitignore`; 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 with `anchor│content` hits usable directly for `replace`/`insert`; use `literal`/`glob`/`context`
1
+ Search with `lineNumber │ 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. 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
+ Search text files for a pattern using ripgrep. Every hit and each `context` line is returned as `lineNumber │ anchor│content` the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` and the `=== path ===` header give file and line for navigation. Respects `.gitignore` via ripgrep; 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.
package/src/commit.ts CHANGED
@@ -4,8 +4,9 @@ import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-res
4
4
  import { saveUndo } from "./replace-undo";
5
5
  import { safeSnapId } from "./file-reader";
6
6
  import { writeAtomic } from "./fs-write";
7
- import { recordServedDiffSafe } from "./served";
8
- import { restoreEndings } from "./replace-diff";
7
+ import { recordServedSafe, buildServedMap, servedHashesFromDiff } from "./served";
8
+ import { restoreEndings } from "./normalize";
9
+ import { splitLines } from "./utils";
9
10
 
10
11
  export interface CommitMeta {
11
12
  path: string;
@@ -72,6 +73,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
72
73
  await writeAtomic(
73
74
  absolutePath,
74
75
  pipe.bom + restoreEndings(pipe.result, pipe.originalEnding),
76
+ pipe.identity,
75
77
  );
76
78
  } catch (error) {
77
79
  await undo.restore();
@@ -101,7 +103,10 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
101
103
  };
102
104
  const changed = buildChanged(successInput, meta.verb);
103
105
  if (changed.details.diff) {
104
- await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff", new Set(pipe.resultHashes));
106
+ const diffHashes = servedHashesFromDiff(changed.details.diff);
107
+ const resultLines = splitLines(pipe.result);
108
+ const servedMap = buildServedMap(pipe.resultHashes, resultLines, diffHashes);
109
+ await recordServedSafe(mutationTargetPath, servedMap, "post-edit diff", new Set(pipe.resultHashes));
105
110
  }
106
111
  return changed;
107
112
  }
package/src/constants.ts CHANGED
@@ -7,6 +7,6 @@ export const MAX_HASH_SOURCE_BYTES = 500;
7
7
  export const MAX_GREP_LINE_BYTES = 500;
8
8
 
9
9
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
10
- export const HASH_STORE_VERSION = 5;
10
+ export const HASH_STORE_VERSION = 6;
11
11
  export const NEW_CONTENT_NOT_ARRAY_MSG =
12
12
  `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
@@ -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
@@ -2,6 +2,7 @@ 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
4
  import { assertLineLimit, lineLimitMoreThanMessage } from "./utils";
5
+ import type { FileIdentity } from "./fs-write";
5
6
 
6
7
  const IMG_TYPES = new Set<string>([
7
8
  "image/bmp",
@@ -54,7 +55,7 @@ function looksLikeText(sample: Uint8Array): boolean {
54
55
  export type LFile =
55
56
  | { kind: "directory" }
56
57
  | { kind: "image"; mimeType: string }
57
- | { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
58
+ | { kind: "text"; text: string; identity?: FileIdentity; hadUtf8DecodeErrors?: true }
58
59
  | { kind: "binary"; description: string }
59
60
  | { kind: "too_large"; description: string };
60
61
 
@@ -87,6 +88,8 @@ export async function loadFileKindAndText(
87
88
 
88
89
  const fileHandle = await fsOpen(filePath, "r");
89
90
  try {
91
+ const openedStats = await fileHandle.stat();
92
+ const identity = { dev: openedStats.dev, ino: openedStats.ino };
90
93
  const buffer = Buffer.alloc(SNIFF_BYTES);
91
94
  const { bytesRead } = await fileHandle.read(
92
95
  buffer,
@@ -95,7 +98,7 @@ export async function loadFileKindAndText(
95
98
  0,
96
99
  );
97
100
  if (bytesRead === 0) {
98
- return { kind: "text", text: "" };
101
+ return { kind: "text", text: "", identity };
99
102
  }
100
103
 
101
104
  const sample = buffer.subarray(0, bytesRead);
@@ -179,6 +182,7 @@ export async function loadFileKindAndText(
179
182
  return {
180
183
  kind: "text",
181
184
  text,
185
+ identity,
182
186
  ...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
183
187
  };
184
188
  } finally {
@@ -3,9 +3,9 @@ import { stat } from "fs/promises";
3
3
  import { relative } from "path";
4
4
  import { lineHashes } from "./hashline";
5
5
  import { loadFileKindAndText, type LFile } from "./file-kind";
6
- import { resolveTarget } from "./fs-write";
6
+ import { resolveTarget, type FileIdentity } from "./fs-write";
7
7
  import { toCwd } from "./paths";
8
- import { detectEnding, toLF, stripBOM, type LineEnding } from "./replace-diff";
8
+ import { detectEnding, toLF, stripBOM, type LineEnding } from "./normalize";
9
9
  import { abortIf, errCode, assertLineLimit } from "./utils";
10
10
  import { valKind, valAccess } from "./validation";
11
11
  import type { HashStore } from "./hash-store";
@@ -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 = {
@@ -94,12 +95,18 @@ export async function readNormFile(
94
95
  if (options?.maxLines !== undefined) assertLineLimit(normalized, path, options.maxLines);
95
96
 
96
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
+ }
97
103
  return {
98
104
  absolutePath: resolvedPath,
99
105
  normalized,
100
106
  bom,
101
107
  originalEnding,
102
108
  fileHashes,
109
+ identity,
103
110
  hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
104
111
  };
105
112
  }
package/src/fs-write.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "crypto";
2
+ import { constants } from "fs";
2
3
  import {
3
- chmod,
4
4
  lstat,
5
5
  mkdir,
6
6
  open,
@@ -15,6 +15,22 @@ import { dirname, join, parse, resolve, sep } from "path";
15
15
  import { toCwd } from "./paths";
16
16
  import { errCode } from "./utils";
17
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
+
18
34
  export async function resolveTarget(path: string): Promise<string> {
19
35
  const absolutePath = resolve(path);
20
36
  const { root } = parse(absolutePath);
@@ -128,6 +144,7 @@ export async function resolveInCwd(path: string, cwd: string): Promise<{ absolut
128
144
  export async function writeAtomic(
129
145
  path: string,
130
146
  content: string,
147
+ expectedIdentity?: FileIdentity,
131
148
  ): Promise<void> {
132
149
  const targetPath = await resolveTarget(path);
133
150
 
@@ -140,19 +157,25 @@ export async function writeAtomic(
140
157
  }
141
158
  }
142
159
 
160
+ if (expectedIdentity && (!existingStats || !sameIdentity(existingStats, expectedIdentity))) {
161
+ throw pathChanged(path);
162
+ }
163
+
143
164
  if (existingStats && existingStats.nlink > 1) {
144
- await writeFile(targetPath, content, "utf-8");
145
- try {
146
- await chmod(targetPath, existingStats.mode & 0o7777);
147
- } catch {}
165
+ const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
166
+ const handle = await open(targetPath, constants.O_WRONLY | noFollow);
148
167
  try {
149
- const handle = await open(targetPath, "r");
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"));
150
172
  try {
151
- await handle.sync();
152
- } finally {
153
- await handle.close();
154
- }
155
- } catch {}
173
+ await handle.chmod(existingStats.mode & 0o7777);
174
+ } catch {}
175
+ await handle.sync();
176
+ } finally {
177
+ await handle.close();
178
+ }
156
179
  return;
157
180
  }
158
181
 
@@ -174,6 +197,14 @@ export async function writeAtomic(
174
197
  }
175
198
  try {
176
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
+ }
177
208
  await rename(tempPath, targetPath);
178
209
  await syncDir(dir);
179
210
  } catch (error: unknown) {