pi-hashline-edit-pro 2.7.2 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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). |
@@ -235,6 +235,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
235
235
  | `[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
236
  | `[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
237
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
238
+ | `[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). |
238
239
 
239
240
  ## Troubleshooting
240
241
 
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.0",
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",
@@ -45,7 +45,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": {
@@ -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/file-kind.ts CHANGED
@@ -1,6 +1,7 @@
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";
4
5
 
5
6
  const IMG_TYPES = new Set<string>([
6
7
  "image/bmp",
@@ -141,11 +142,7 @@ export async function loadFileKindAndText(
141
142
  for (let i = 0; i < decoded.length; i++) {
142
143
  if (decoded.charCodeAt(i) === 10) newlineCount++;
143
144
  }
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
- }
145
+ if (newlineCount > options.maxLines) throw new Error(lineLimitMoreThanMessage(options.displayPath ?? filePath, options.maxLines));
149
146
  }
150
147
  return decoded;
151
148
  }
@@ -169,6 +166,11 @@ export async function loadFileKindAndText(
169
166
  position += chunkBytesRead;
170
167
  }
171
168
  parts.push(decodeChunk(new Uint8Array(0), false));
169
+ const text = parts.join("");
170
+ if (options?.maxLines !== undefined && text.length > 0) {
171
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
172
+ assertLineLimit(normalized, options.displayPath ?? filePath, options.maxLines);
173
+ }
172
174
 
173
175
  if (containsNul) {
174
176
  return { kind: "binary", description: "contains NUL bytes" };
@@ -176,7 +178,7 @@ export async function loadFileKindAndText(
176
178
 
177
179
  return {
178
180
  kind: "text",
179
- text: parts.join(""),
181
+ text,
180
182
  ...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
181
183
  };
182
184
  } 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
6
  import { resolveTarget } from "./fs-write";
6
7
  import { toCwd } from "./paths";
7
8
  import { detectEnding, toLF, stripBOM, type LineEnding } from "./replace-diff";
8
- import { abortIf } from "./utils";
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;
@@ -52,7 +52,7 @@ export async function safeSnapId(
52
52
  try {
53
53
  return (await fileSnap(absolutePath)).snapshotId;
54
54
  } catch (error) {
55
- console.error(`Failed to compute snapshot (${context}):`, error);
55
+ console.error(`[safeSnapId] ${context}: failed to stat "${absolutePath}" (code=${errCode(error) ?? "?"}):`, error);
56
56
  return undefined;
57
57
  }
58
58
  }
@@ -91,14 +91,7 @@ export async function readNormFile(
91
91
  const originalEnding = detectEnding(rawContent);
92
92
  const normalized = toLF(rawContent);
93
93
 
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
- }
94
+ if (options?.maxLines !== undefined) assertLineLimit(normalized, path, options.maxLines);
102
95
 
103
96
  const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store, options?.noPersist !== true);
104
97
  return {
@@ -110,3 +103,24 @@ export async function readNormFile(
110
103
  hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
111
104
  };
112
105
  }
106
+
107
+ export async function tryReadNormFile(
108
+ absPath: string,
109
+ cwd: string,
110
+ options?: ReadNormOptions,
111
+ ): Promise<NormFile | undefined> {
112
+ try {
113
+ const displayPath = relative(cwd, absPath).replace(/\\/g, "/") || absPath;
114
+ const file = await loadFileKindAndText(absPath, { maxLines: options?.maxLines, displayPath });
115
+ if (file.kind !== "text") return undefined;
116
+ return await readNormFile(absPath, cwd, { ...options, preloadedFile: file });
117
+ } catch (error) {
118
+ const code = errCode(error);
119
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT" || code === "ELOOP") return undefined;
120
+ if (error instanceof Error) {
121
+ const msg = error.message;
122
+ if (msg.startsWith("[E_FILE_TOO_LARGE]") || msg.startsWith("[E_NOT_FOUND]") || msg.startsWith("[E_ACCESS]") || msg.startsWith("[E_NOT_TEXT]")) return undefined;
123
+ }
124
+ throw error;
125
+ }
126
+ }
package/src/fs-write.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import {
3
+ chmod,
3
4
  lstat,
4
5
  mkdir,
5
6
  open,
@@ -11,6 +12,7 @@ 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
 
16
18
  export async function resolveTarget(path: string): Promise<string> {
@@ -25,7 +27,15 @@ export async function resolveTarget(path: string): Promise<string> {
25
27
  async function resParts(
26
28
  currentPath: string,
27
29
  remainingParts: string[],
30
+ symlinkDepth = 0,
28
31
  ): Promise<string> {
32
+ if (symlinkDepth > 40) {
33
+ const error = new Error(
34
+ `Too many symbolic links while resolving ${path}`,
35
+ ) as NodeJS.ErrnoException;
36
+ error.code = "ELOOP";
37
+ throw error;
38
+ }
29
39
  if (remainingParts.length === 0) {
30
40
  return currentPath;
31
41
  }
@@ -36,7 +46,7 @@ export async function resolveTarget(path: string): Promise<string> {
36
46
  try {
37
47
  const candidateStats = await lstat(candidatePath);
38
48
  if (!candidateStats.isSymbolicLink()) {
39
- return resParts(candidatePath, tail);
49
+ return resParts(candidatePath, tail, symlinkDepth);
40
50
  }
41
51
 
42
52
  if (visitedSymlinks.has(candidatePath)) {
@@ -59,7 +69,7 @@ export async function resolveTarget(path: string): Promise<string> {
59
69
  return resParts(parse(linkTargetPath).root, [
60
70
  ...targetParts,
61
71
  ...tail,
62
- ]);
72
+ ], symlinkDepth + 1);
63
73
  } catch (error: unknown) {
64
74
  if (errCode(error) === "ENOENT") {
65
75
  return join(candidatePath, ...tail);
@@ -110,6 +120,11 @@ async function syncDir(dir: string): Promise<void> {
110
120
  }
111
121
  }
112
122
 
123
+ export async function resolveInCwd(path: string, cwd: string): Promise<{ absolute: string; resolved: string }> {
124
+ const absolute = toCwd(path, cwd);
125
+ const resolved = await resolveTarget(absolute);
126
+ return { absolute, resolved };
127
+ }
113
128
  export async function writeAtomic(
114
129
  path: string,
115
130
  content: string,
@@ -127,6 +142,17 @@ export async function writeAtomic(
127
142
 
128
143
  if (existingStats && existingStats.nlink > 1) {
129
144
  await writeFile(targetPath, content, "utf-8");
145
+ try {
146
+ await chmod(targetPath, existingStats.mode & 0o7777);
147
+ } catch {}
148
+ try {
149
+ const handle = await open(targetPath, "r");
150
+ try {
151
+ await handle.sync();
152
+ } finally {
153
+ await handle.close();
154
+ }
155
+ } catch {}
130
156
  return;
131
157
  }
132
158
 
package/src/grep.ts CHANGED
@@ -3,13 +3,12 @@ import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult
3
3
  import { Type } from "typebox";
4
4
  import { readdir, stat } from "fs/promises";
5
5
  import { dirname, join, relative } from "path";
6
- import { loadFileKindAndText } from "./file-kind";
7
- import { readNormFile } from "./file-reader";
6
+ import { tryReadNormFile } from "./file-reader";
8
7
  import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP } from "./hashline";
9
8
  import { MAX_GREP_LINE_BYTES } from "./constants";
10
9
  import { toCwd } from "./paths";
11
10
  import { loadP, loadGuide } from "./prompts";
12
- import { normReq } from "./replace-normalize";
11
+ import { normReq } from "./payload-contract";
13
12
  import { recordServedSafe } from "./served";
14
13
  import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
15
14
 
@@ -17,6 +16,10 @@ const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "li
17
16
  const SKIP_DIRS = new Set(["node_modules", ".git", ".tmp", "coverage"]);
18
17
  const MAX_SCAN_FILES = 4000;
19
18
 
19
+ function cmp(a: string, b: string): number {
20
+ return a < b ? -1 : a > b ? 1 : 0;
21
+ }
22
+
20
23
  export interface GrepReq {
21
24
  pattern: string;
22
25
  path?: string;
@@ -53,6 +56,7 @@ function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): Reg
53
56
  }
54
57
 
55
58
  function globToRegex(glob: string): RegExp {
59
+ if (glob.startsWith("/")) glob = glob.slice(1);
56
60
  let source = "";
57
61
  let i = 0;
58
62
  while (i < glob.length) {
@@ -79,12 +83,6 @@ function globToRegex(glob: string): RegExp {
79
83
  return new RegExp(`^${source}$`);
80
84
  }
81
85
 
82
- function isSkipableLoadError(error: unknown): boolean {
83
- const code = errCode(error);
84
- if (code === "EACCES" || code === "EPERM" || code === "ENOENT" || code === "ELOOP") return true;
85
- return error instanceof Error && error.message.startsWith("[E_FILE_TOO_LARGE]");
86
- }
87
-
88
86
  interface FileHit {
89
87
  path: string;
90
88
  displayPath: string;
@@ -142,14 +140,16 @@ async function walkFiles(
142
140
  onFile: (absPath: string) => Promise<void>,
143
141
  ): Promise<void> {
144
142
  const queue: string[] = [root];
145
- while (queue.length > 0 && !state.stopped) {
146
- const dir = queue.pop()!;
143
+ let head = 0;
144
+ while (head < queue.length && !state.stopped) {
145
+ const dir = queue[head++]!;
147
146
  let entries;
148
147
  try {
149
148
  entries = await readdir(dir, { withFileTypes: true });
150
149
  } catch {
151
150
  continue;
152
151
  }
152
+ entries.sort((a, b) => cmp(a.name, b.name));
153
153
  for (const entry of entries) {
154
154
  if (state.stopped) break;
155
155
  const full = join(dir, entry.name);
@@ -180,23 +180,10 @@ async function searchFile(
180
180
  const displayPath = relative(cwd, absPath).replace(/\\/g, "/");
181
181
  if (globRegex) {
182
182
  const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
183
- if (!globRegex.test(globPath)) return undefined;
184
- }
185
- let file;
186
- try {
187
- file = await loadFileKindAndText(absPath, { maxLines: MAX_HASH_LINES, displayPath });
188
- } catch (error) {
189
- if (isSkipableLoadError(error)) return undefined;
190
- throw error;
191
- }
192
- if (file.kind !== "text") return undefined;
193
- let norm;
194
- try {
195
- norm = await readNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file, noPersist: true });
196
- } catch (error) {
197
- if (isSkipableLoadError(error)) return undefined;
198
- throw error;
183
+ if (!globRegex.test(globPath) && !globRegex.test(displayPath)) return undefined;
199
184
  }
185
+ const norm = await tryReadNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, noPersist: true });
186
+ if (!norm) return undefined;
200
187
  const lines = visLines(norm.normalized);
201
188
  const matchLines: number[] = [];
202
189
  for (let i = 0; i < lines.length; i++) {
@@ -252,7 +239,7 @@ const grepToolSchema = Type.Object(
252
239
  ),
253
240
  glob: Type.Optional(
254
241
  Type.String({
255
- description: "Filter files by glob pattern; * matches across directories, e.g. '*.ts' or '**/*.spec.ts'",
242
+ description: "Filter files by glob pattern; * matches across directories, e.g. '*.ts' or '**/*.spec.ts'. A leading / is ignored; the pattern may be relative to the search root or to the current directory.",
256
243
  }),
257
244
  ),
258
245
  ignoreCase: Type.Optional(
@@ -290,6 +277,7 @@ export function regGrep(pi: ExtensionAPI): void {
290
277
  promptGuidelines: loadGuide("../prompts/grep-guidelines.md"),
291
278
  prepareArguments: makePrepareArguments(),
292
279
  parameters: grepToolSchema,
280
+ executionMode: "sequential",
293
281
 
294
282
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
295
283
  const canonical = normReq(params);
@@ -319,6 +307,7 @@ export function regGrep(pi: ExtensionAPI): void {
319
307
  await walkFiles(base, state, async (absPath) => {
320
308
  files.push(absPath);
321
309
  });
310
+ files.sort(cmp);
322
311
  }
323
312
  const hits: FileHit[] = [];
324
313
  let matches = 0;
@@ -382,6 +371,7 @@ export function regGrep(pi: ExtensionAPI): void {
382
371
  hits.push({ ...hit, rows: keptRows, hashes: keptHashes });
383
372
  if (rowTruncated) countOnly = true;
384
373
  }
374
+ hits.sort((a, b) => cmp(a.displayPath, b.displayPath));
385
375
  for (const hit of hits) {
386
376
  await recordServedSafe(hit.path, hit.hashes, "grep", new Set(hit.fileHashes));
387
377
  }