pi-hashline-edit-pro 4.4.2 → 4.4.3

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
@@ -230,7 +230,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
230
230
 
231
231
  | Code | Meaning |
232
232
  | --- | --- |
233
- | `[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), or content contains a NUL byte (`U+0000`), which would make the file binary. |
233
+ | `[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
234
  | `[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
235
  | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
236
236
  | `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
@@ -252,6 +252,8 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
252
252
  | `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
253
253
  | `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
254
254
  | `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
255
+ | `[E_GREP_FAILED]` | `anchor_grep` could not start ripgrep or ripgrep exited with an error (for example a pattern valid in JavaScript but unsupported by ripgrep's regex engine); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
256
+ | `[E_GREP_TIMEOUT]` | `anchor_grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
255
257
  | `[E_AUTO_READ_ALL]` | File already attached and unchanged since this session's start; the attached content is still exact. |
256
258
 
257
259
  ## Troubleshooting
@@ -263,6 +265,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
263
265
  - Reset the anchor state. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars) and in per-session ownership logs under `~/.config/pi-hashline-edit-pro/sessions/`. Quit pi, delete those files, and everything is rebuilt on the next session. Anchor history is lost, but no project files are touched.
264
266
  - Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
265
267
  - 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
+ - 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.
266
269
 
267
270
  ## Development
268
271
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.4.2",
3
+ "version": "4.4.3",
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",
package/src/fs-write.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  } from "node:fs/promises";
14
14
  import { dirname, join, parse, resolve, sep } from "node:path";
15
15
  import { toCwd } from "./paths";
16
- import { errCode } from "./utils";
16
+ import { errCode, isModeUnsupported } from "./utils";
17
17
 
18
18
  export interface FileIdentity {
19
19
  dev: number;
@@ -196,7 +196,11 @@ export async function writeAtomic(
196
196
  await tempHandle.writeFile(content, "utf-8");
197
197
  const mode = existingStats ? existingStats.mode & 0o7777 : restoreMode;
198
198
  if (mode !== undefined) {
199
- await tempHandle.chmod(mode);
199
+ try {
200
+ await tempHandle.chmod(mode);
201
+ } catch (error) {
202
+ if (!isModeUnsupported(error)) throw error;
203
+ }
200
204
  }
201
205
  await tempHandle.sync();
202
206
  } catch (error: unknown) {
package/src/grep.ts CHANGED
@@ -13,11 +13,13 @@ import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
13
13
  import { toCwd, toDisplayPath } from "./paths";
14
14
  import { loadP, loadGuide } from "./prompts";
15
15
  import { normReq } from "./payload-contract";
16
- import { abortIf, errCode, gutterWidth, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
16
+ import { abortIf, clipLine, errCode, gutterWidth, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
17
17
  import { withAnchorSession } 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";
21
+ export const RG_TIMEOUT_MS = 10_000;
22
+
21
23
  const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
22
24
 
23
25
  function cmp(a: string, b: string): number {
@@ -54,6 +56,14 @@ export function assertGrepReq(request: unknown): asserts request is GrepReq {
54
56
  }
55
57
  }
56
58
 
59
+ function compileGrepGlob(glob: string): RegExp {
60
+ try {
61
+ return globToRegex(glob);
62
+ } catch {
63
+ throw new Error(`[E_BAD_SHAPE] Invalid glob pattern: ${glob}. Fix the bracket or brace syntax, or search without glob.`);
64
+ }
65
+ }
66
+
57
67
  function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
58
68
  if (!literal) assertSafeRegex(pattern);
59
69
  const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
@@ -314,8 +324,8 @@ async function collectRgMatches(
314
324
  const rgTimeout = setTimeout(() => {
315
325
  timedOut = true;
316
326
  if (!child.killed) child.kill("SIGKILL");
317
- reject(new Error("rg timeout"));
318
- }, 10000);
327
+ reject(new Error(`[E_GREP_TIMEOUT] ripgrep timed out after ${RG_TIMEOUT_MS}ms. Narrow the path or pattern and retry.`));
328
+ }, RG_TIMEOUT_MS);
319
329
  child.stderr?.on("data", (chunk) => {
320
330
  stderr += chunk.toString();
321
331
  });
@@ -355,7 +365,8 @@ async function collectRgMatches(
355
365
  child.on("error", (error) => {
356
366
  cleanup();
357
367
  if (rgPath === cachedRgPath) cachedRgPath = undefined;
358
- reject(error);
368
+ const detail = error instanceof Error ? error.message : String(error);
369
+ reject(new Error(`[E_GREP_FAILED] ripgrep could not start: ${clipLine(detail)}`));
359
370
  });
360
371
  child.on("close", (code) => {
361
372
  cleanup();
@@ -365,8 +376,10 @@ async function collectRgMatches(
365
376
  return;
366
377
  }
367
378
  if (code !== 0 && code !== 1) {
368
- const msg = stderr.trim() || `ripgrep exited with code ${code}`;
369
- reject(new Error(msg));
379
+ const detail = clipLine(stderr.trim(), 300);
380
+ reject(new Error(detail
381
+ ? `[E_GREP_FAILED] ripgrep exited with code ${code}: ${detail}`
382
+ : `[E_GREP_FAILED] ripgrep exited with code ${code}.`));
370
383
  return;
371
384
  }
372
385
  resolve(result);
@@ -538,7 +551,7 @@ export function regGrep(pi: ExtensionAPI): void {
538
551
  throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
539
552
  }
540
553
  const globRoot = baseStat.isFile() ? dirname(base) : base;
541
- const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
554
+ const globRegex = req.glob === undefined ? undefined : compileGrepGlob(req.glob);
542
555
  const matchesGlob = (absPath: string): boolean => {
543
556
  if (globRegex === undefined) return true;
544
557
  const displayPath = toDisplayPath(ctx.cwd, absPath);
package/src/hash-store.ts CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { chmod, readFile, rename, mkdir, stat } from "node:fs/promises";
4
4
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
5
- import { errCode, isRec, splitLines } from "./utils";
5
+ import { errCode, isModeUnsupported, isRec, splitLines } from "./utils";
6
6
  import { initHasher, contentChecksum, lineChecksum } from "./hashline/hasher";
7
7
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
8
8
  import {
@@ -278,7 +278,11 @@ async function openStore(storePath: string): Promise<HashStore> {
278
278
  await initHasher();
279
279
  await mkdir(hashStoreDir(), { recursive: true, mode: 0o700 });
280
280
  if (process.platform !== "win32") {
281
- await chmod(hashStoreDir(), 0o700);
281
+ try {
282
+ await chmod(hashStoreDir(), 0o700);
283
+ } catch (error) {
284
+ if (errCode(error) !== "ENOENT" && !isModeUnsupported(error)) throw error;
285
+ }
282
286
  }
283
287
 
284
288
  let existed = existsSync(storePath);
@@ -319,7 +323,7 @@ async function openStore(storePath: string): Promise<HashStore> {
319
323
  try {
320
324
  await chmod(candidate, 0o600);
321
325
  } catch (error) {
322
- if (errCode(error) !== "ENOENT") throw error;
326
+ if (errCode(error) !== "ENOENT" && !isModeUnsupported(error)) throw error;
323
327
  }
324
328
  }
325
329
  }
package/src/utils.ts CHANGED
@@ -99,6 +99,11 @@ export function errCode(error: unknown): string | undefined {
99
99
  return undefined;
100
100
  }
101
101
 
102
+ export function isModeUnsupported(error: unknown): boolean {
103
+ const code = errCode(error);
104
+ return code === "EPERM" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EINVAL" || code === "ENOSYS";
105
+ }
106
+
102
107
  export function truncateToBytes(s: string, maxBytes: number): string {
103
108
  if (Buffer.byteLength(s, "utf-8") <= maxBytes) return s;
104
109
  let out = "";