pi-hashline-edit-pro 4.5.1 → 4.5.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
@@ -186,7 +186,7 @@ Edge cases:
186
186
  | --- | --- |
187
187
  | `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
188
188
  | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
189
- | `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
189
+ | `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets the line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A trailing line-break sets the ending of the element's last line instead of adding a blank line, so `["b\n"]` sets that line's ending to LF, `["b\r\n"]` to CRLF, and on a file without a final newline it adds it. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
190
190
 
191
191
  Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
192
192
 
@@ -208,7 +208,7 @@ Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before
208
208
 
209
209
  Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
210
210
 
211
- An edit that produces identical content reports `No changes made` and leaves the anchors alone.
211
+ An edit that changes neither content nor line endings reports `No changes made` and leaves the anchors alone.
212
212
 
213
213
  After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as a marker that keeps the row's anchor, and only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch`.
214
214
 
@@ -220,7 +220,7 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
220
220
  | --- | --- |
221
221
  | `anchor` | 4-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
222
222
  | `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
223
- | `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. A lone string is split on newlines, and stringified array text is unwrapped. |
223
+ | `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets the line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A trailing line-break sets the ending of the element's last line instead of adding a blank line, so `["b\n"]` inserts `b` with an LF ending, and on a file without a final newline it adds it. A lone string is split on newlines, and stringified array text is unwrapped. |
224
224
 
225
225
  Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
226
226
 
@@ -314,6 +314,8 @@ Auto-read is enabled by default. After a successful `write`, the extension reads
314
314
 
315
315
  After `replace`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
316
316
 
317
+ An edit that changes only line endings has no content diff; the result still reports `applied`, and one `undo_last_change` reverts it.
318
+
317
319
  Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff context live in `/hashline-config` and persist across sessions. The post-edit diff shows 1 surrounding line by default; change Diff context in `/hashline-config` (0-10, needs Auto-read) to show more or fewer.
318
320
 
319
321
  ## Auto-read all
@@ -357,7 +359,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
357
359
  | `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
358
360
  | `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
359
361
  | `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
360
- | `requirePath` | Require path | `false` | `replace` and `insert` require a `path` argument that must match anchor ownership. |
362
+ | `requirePath` | Require path | `false` | `replace`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
361
363
  | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
362
364
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
363
365
 
@@ -369,7 +371,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
369
371
  | --- | --- | --- |
370
372
  | Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
371
373
  | Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
372
- | Line cap | 1,353,139 lines per file | `read`, `replace`, `insert` (`[E_FILE_TOO_LARGE]`) |
374
+ | Line cap | 1,353,139 lines per file | `read`, `replace`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
373
375
  | File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
374
376
  | Hash window | first 500 bytes of a line | anchor identity for long lines |
375
377
  | Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
@@ -423,7 +425,7 @@ Full reference:
423
425
  | `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
424
426
  | `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
425
427
  | `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
426
- | `[H_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains the literal escaped text such as `\u200b` or `\n`; the file receives those characters as written. Decode the escapes first if the text came from a quoted prompt. |
428
+ | `[H_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains literal escaped text such as `\uXXXX` or `\n`; the file receives those backslash characters as written. Escapes decode once in the tool call (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally — resend with the real character if that was not intended. |
427
429
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. A cross-file `move` may empty its source file. |
428
430
  | `[E_NOT_FOUND]` | The path does not exist. |
429
431
  | `[E_ACCESS]` | The file is not readable or writable. |
@@ -479,7 +481,7 @@ Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` withou
479
481
 
480
482
  ### Allocation
481
483
 
482
- Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 26⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
484
+ Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 52⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
483
485
 
484
486
  ### Ownership and mapping across edits
485
487
 
package/index.ts CHANGED
@@ -280,7 +280,8 @@ export default function (pi: ExtensionAPI): void {
280
280
  const warnings = detailWarnings.length ? `Warnings:\n${detailWarnings.join("\n")}` : extractWarnings(rendered);
281
281
  const hints = detailHints.length ? `Hints:\n${detailHints.join("\n")}` : extractHints(rendered);
282
282
  const notices = [warnings, hints].filter((part): part is string => part !== undefined).join("\n\n");
283
- const noticeText = hasDiff ? (notices ? `${diff}\n\n${notices}` : diff) : notices ? `[post-edit] applied successfully; the diff is empty (whitespace-only change).\n\n${notices}` : "[post-edit] applied successfully; the diff is empty (whitespace-only change).";
283
+ const emptyDiffNotice = "[post-edit] applied successfully; the diff is empty (no content change: whitespace or line endings only).";
284
+ const noticeText = hasDiff ? (notices ? `${diff}\n\n${notices}` : diff) : notices ? `${emptyDiffNotice}\n\n${notices}` : emptyDiffNotice;
284
285
  return {
285
286
  content: [
286
287
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.5.1",
3
+ "version": "4.5.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/prompts/insert.md CHANGED
@@ -1,3 +1,3 @@
1
- Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending. Inserted lines are written exactly as given; nothing else in the file changes.
1
+ Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Inserted lines are written exactly as given; nothing else in the file changes.
2
2
 
3
3
  Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
@@ -1,4 +1,4 @@
1
1
  - `replace`: `-anchor│` rows in a post-edit diff are dead anchors; only `+anchor│` and ` anchor│` rows are live. Check the post-edit diff before the next turn's edits on that file.
2
2
  - `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone; its file's batch still commits.
3
- - `replace`: use `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
3
+ - `replace`: use `replace`/`insert` for structural or multi-line edits — anchored, verified, undoable.
4
4
  - `replace`: `replacement_lines`: one string per line, `[""]` is one blank line, `[]` deletes. Pasted `anchor│` prefixes are stripped. Single line: same anchor for `remove_from` and `remove_to`.
@@ -1,4 +1,4 @@
1
- Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending. `[]` deletes the range.
1
+ Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. `[]` deletes the range.
2
2
 
3
3
  Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
4
4
 
package/src/batch.ts CHANGED
@@ -543,13 +543,31 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
543
543
 
544
544
  function composeBatchLines(baseContent: string, pieces: BatchPiece[]): string {
545
545
  const lines = splitLines(baseContent);
546
+ const baseLineCount = lines.length;
546
547
  const descending = [...pieces].sort((a, b) => b.start - a.start);
547
548
  for (const piece of descending) lines.splice(piece.start - 1, piece.end - piece.start + 1, ...piece.newLines);
548
549
  let composed = lines.join("\n");
549
- if (lines.length > 0 && (baseContent.endsWith("\n") || lines[lines.length - 1] === "")) composed += "\n";
550
+ if (lines.length > 0 && (baseContent.endsWith("\n") || lines[lines.length - 1] === "")) {
551
+ composed += "\n";
552
+ } else if (lines.length > 0) {
553
+ const trailing = pieces.reduce<BatchPiece | undefined>((best, piece) => (best === undefined || piece.end > best.end ? piece : best), undefined);
554
+ const lastEnding = trailing?.separators?.[trailing.newLines.length - 1];
555
+ if (trailing !== undefined && trailing.end === baseLineCount && lastEnding !== undefined) composed += "\n";
556
+ }
550
557
  return composed;
551
558
  }
552
559
 
560
+ function changesEnding(piece: BatchPiece, baseSeparators: LineEnding[]): boolean {
561
+ if (piece.separators === undefined) return false;
562
+ for (let index = 0; index < piece.separators.length; index++) {
563
+ const ending = piece.separators[index];
564
+ if (ending === undefined) continue;
565
+ const baseIndex = piece.start - 1 + index;
566
+ if (baseIndex >= baseSeparators.length || baseSeparators[baseIndex] !== ending) return true;
567
+ }
568
+ return false;
569
+ }
570
+
553
571
  function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
554
572
  const byAnchor = new Map<number, BatchPiece[]>();
555
573
  for (const piece of pieces) {
@@ -624,11 +642,12 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
624
642
  }
625
643
  }
626
644
  const appliedPieces = runtime.pieces.filter((piece) => !piece.noop);
627
- if (appliedPieces.length === 0) {
645
+ const candidatePieces = runtime.pieces.filter((piece) => !piece.noop || changesEnding(piece, base.separators));
646
+ if (candidatePieces.length === 0) {
628
647
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
629
648
  return combinedNoop(paths.displayPath, member, runtime, snapshotId);
630
649
  }
631
- const effectivePieces = mergeInsertPairs(appliedPieces);
650
+ const effectivePieces = mergeInsertPairs(candidatePieces);
632
651
  const ordered = [...effectivePieces].sort((a, b) => a.start - b.start);
633
652
  for (let i = 1; i < ordered.length; i++) {
634
653
  const prev = ordered[i - 1]!;
@@ -649,11 +668,12 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
649
668
  })));
650
669
  const warnings = [...runtime.warnings];
651
670
  if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
671
+ const finalBytes = base.bom + joinSeparators(composed, resultSeparators);
672
+ const originalBytes = base.bom + joinSeparators(base.content, base.separators);
652
673
  try {
653
674
  await throwIfStrictInput(dedupeWarnings(warnings));
654
675
  assertNotEmpty(base.content, composed);
655
676
  assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
656
- const finalBytes = base.bom + joinSeparators(composed, resultSeparators);
657
677
  assertByteLimit(finalBytes, paths.displayPath);
658
678
  } catch (error) {
659
679
  discardBatchState(runtime);
@@ -662,7 +682,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
662
682
  }
663
683
  const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
664
684
  if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
665
- if (composed === base.content) {
685
+ if (finalBytes === originalBytes) {
666
686
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
667
687
  return combinedNoop(paths.displayPath, member, runtime, snapshotId);
668
688
  }
@@ -756,8 +776,10 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
756
776
  batchVerb(runtime),
757
777
  await getDiffContextLines(),
758
778
  );
759
- changed.details.diff = `${header}\n${changed.details.diff}`;
760
- changed.details.diffLineNumbers?.unshift(null);
779
+ if (changed.details.diff.length > 0) {
780
+ changed.details.diff = `${header}\n${changed.details.diff}`;
781
+ changed.details.diffLineNumbers?.unshift(null);
782
+ }
761
783
  try {
762
784
  serveRows(runtime.target, resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff));
763
785
  } catch (error) {
package/src/commit.ts CHANGED
@@ -33,7 +33,22 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
33
33
  const readReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
34
34
  if (readReclaim !== undefined) warnings.push(readReclaim);
35
35
 
36
- if (pipe.result === pipe.originalNormalized) {
36
+ const span = pipe.spans?.[0] ?? (meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined);
37
+ if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
38
+ const resultSeparators = span
39
+ ? separatorsForSpans(pipe.originalSeparators, pipe.originalHashes.length, [span], pipe.result, pipe.originalEnding)
40
+ : undefined;
41
+ if (resultSeparators !== undefined && span !== undefined) {
42
+ applyEndingOverrides(resultSeparators, span.start, pipe.contentSeparators);
43
+ applyEndingOverrides(resultSeparators, span.start, meta.endingOverrides);
44
+ }
45
+ const finalFileBytes = pipe.bom + (resultSeparators !== undefined
46
+ ? joinSeparators(pipe.result, resultSeparators)
47
+ : restoreEndings(pipe.result, pipe.originalEnding));
48
+ const contentUnchanged = pipe.result === pipe.originalNormalized;
49
+ const bytesUnchanged = contentUnchanged && (span === undefined || finalFileBytes === pipe.bom + joinSeparators(pipe.originalNormalized, pipe.originalSeparators));
50
+
51
+ if (bytesUnchanged) {
37
52
  const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
38
53
  return buildNoop(
39
54
  {
@@ -52,18 +67,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
52
67
  );
53
68
  }
54
69
 
55
- const span = pipe.spans?.[0] ?? (meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined);
56
- if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
57
- const resultSeparators = span
58
- ? separatorsForSpans(pipe.originalSeparators, pipe.originalHashes.length, [span], pipe.result, pipe.originalEnding)
59
- : undefined;
60
- if (resultSeparators !== undefined && span !== undefined) {
61
- applyEndingOverrides(resultSeparators, span.start, pipe.contentSeparators);
62
- applyEndingOverrides(resultSeparators, span.start, meta.endingOverrides);
63
- }
64
- const finalFileBytes = pipe.bom + (resultSeparators !== undefined
65
- ? joinSeparators(pipe.result, resultSeparators)
66
- : restoreEndings(pipe.result, pipe.originalEnding));
67
70
  assertByteLimit(finalFileBytes, path);
68
71
 
69
72
  if (pipe.hadUtf8DecodeErrors) {
package/src/config-ui.ts CHANGED
@@ -24,7 +24,7 @@ export function configRows(config: Config): ConfigRow[] {
24
24
  { key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
25
25
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
26
26
  { key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
27
- { key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
27
+ { key: "requirePath", label: "Require path", hint: "replace, insert, copy, move need path (RPC visibility)", enabled: config.requirePath === true },
28
28
  { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
29
29
  ];
30
30
  }
package/src/copy-move.ts CHANGED
@@ -544,6 +544,7 @@ async function executeCrossFile(
544
544
  accessMode: constants.R_OK | constants.W_OK,
545
545
  signal,
546
546
  preloadedNorm: prepared.destinationPreload,
547
+ endingOverrides: prepared.endingOverrides,
547
548
  });
548
549
  if (kind === "copy") {
549
550
  return commitEdit(destinationPipe, {
@@ -610,6 +611,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
610
611
  noPersist: true,
611
612
  preloadedNorm: preload,
612
613
  served: plan.servedOverride,
614
+ endingOverrides: plan.endingOverrides,
613
615
  signal,
614
616
  });
615
617
  return previewFromPipe(pipe);
@@ -619,6 +621,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
619
621
  accessMode: constants.R_OK,
620
622
  noPersist: true,
621
623
  preloadedNorm: prepared.destinationPreload,
624
+ endingOverrides: prepared.endingOverrides,
622
625
  signal,
623
626
  });
624
627
  if (kind === "copy") return previewFromPipe(destinationPipe);
@@ -764,6 +767,7 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
764
767
  signal,
765
768
  preloadedNorm: preload,
766
769
  served: plan.servedOverride,
770
+ endingOverrides: plan.endingOverrides,
767
771
  });
768
772
  return commitEdit(pipe, {
769
773
  path: pipe.path,
package/src/grep.ts CHANGED
@@ -298,14 +298,30 @@ export async function resolveRgPath(): Promise<string> {
298
298
  throw new Error("[E_ACCESS] ripgrep (rg) is required for grep but was not found. Install ripgrep or ensure pi can download it to ~/.pi/agent/bin.");
299
299
  }
300
300
 
301
+ async function insideGitRepo(start: string): Promise<boolean> {
302
+ let current = start;
303
+ for (;;) {
304
+ try {
305
+ await stat(join(current, ".git"));
306
+ return true;
307
+ } catch {
308
+ const parent = dirname(current);
309
+ if (parent === current) return false;
310
+ current = parent;
311
+ }
312
+ }
313
+ }
314
+
301
315
  async function collectRgMatches(
302
316
  rgPath: string,
303
317
  pattern: string,
304
318
  searchPath: string,
305
319
  req: GrepReq,
306
- signal?: AbortSignal,
320
+ signal: AbortSignal | undefined,
321
+ repoRooted: boolean,
307
322
  ): Promise<Map<string, number[]>> {
308
323
  const args = ["--json", "--line-number", "--color=never", "--hidden", "--glob", "!.git"];
324
+ if (!repoRooted) args.push("--no-require-git");
309
325
  const wanted = req.limit ?? 100;
310
326
  args.push("--max-count", String(wanted + 1));
311
327
  if (typeof req.glob === "string" && req.glob.length > 0) {
@@ -583,7 +599,8 @@ export function regGrep(pi: ExtensionAPI): void {
583
599
  };
584
600
  const readGrepFile = makeGrepReader("real");
585
601
  const readGrepFileShadow = makeGrepReader("shadow");
586
- const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal);
602
+ const repoRooted = await insideGitRepo(globRoot);
603
+ const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal, repoRooted);
587
604
  const sortedFiles = [...rgMatches.keys()].sort(cmp);
588
605
  for (let f = 0; f < sortedFiles.length; f++) {
589
606
  abortIf(signal);
@@ -68,12 +68,12 @@ function resToSpan(
68
68
  const startLine = edit.hash_bounds[0].line;
69
69
  const endLine = edit.hash_bounds[1].line;
70
70
  const originalLines = fileLines.slice(startLine - 1, endLine);
71
- if (
71
+ const contentUnchanged =
72
72
  originalLines.length === edit.content_lines.length &&
73
73
  originalLines.every(
74
74
  (line, lineIndex) => line === edit.content_lines[lineIndex],
75
- )
76
- ) {
75
+ );
76
+ if (contentUnchanged && edit.content_separators === undefined) {
77
77
  return {
78
78
  kind: "noop",
79
79
  loc: edit.hash_bounds[0].hash,
@@ -82,7 +82,9 @@ function resToSpan(
82
82
  }
83
83
 
84
84
  if (edit.content_lines.length > 0) {
85
- const lastReplacementLine = edit.content_lines[edit.content_lines.length - 1]!;
85
+ const lastIndex = edit.content_lines.length - 1;
86
+ const lastReplacementLine = edit.content_lines[lastIndex]!;
87
+ const lastEnding = edit.content_separators?.[lastIndex];
86
88
  const endsWithBlank = lastReplacementLine.length === 0;
87
89
  const endsAtEofWithoutNewline =
88
90
  endLine === fileLines.length && !content.endsWith("\n");
@@ -92,7 +94,7 @@ function resToSpan(
92
94
  start: lineStarts[startLine - 1]!,
93
95
  end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
94
96
  replacement:
95
- endsAtEofWithoutNewline && endsWithBlank
97
+ endsAtEofWithoutNewline && (endsWithBlank || lastEnding !== undefined)
96
98
  ? `${replacement}\n`
97
99
  : replacement,
98
100
  };
@@ -81,10 +81,14 @@ export function parseTextWithSeparators(edit: string[]): ParsedText {
81
81
  }
82
82
  const lines: string[] = [];
83
83
  const separators: (LineEnding | undefined)[] = [];
84
- for (const line of edit) {
85
- const parsed = splitWithEndings(unwrapJsonEnvelope(line));
86
- lines.push(...parsed.lines);
87
- separators.push(...parsed.endings);
84
+ for (const element of edit) {
85
+ const text = unwrapJsonEnvelope(element);
86
+ const parsed = splitWithEndings(text);
87
+ const lineCount = text.endsWith("\n") || text.endsWith("\r") ? parsed.lines.length - 1 : parsed.lines.length;
88
+ for (let index = 0; index < lineCount; index++) {
89
+ lines.push(parsed.lines[index]!);
90
+ separators.push(parsed.endings[index]!);
91
+ }
88
92
  }
89
93
  return { lines, separators };
90
94
  }
@@ -18,6 +18,7 @@ export type HEdit = {
18
18
  export type RHEdit = {
19
19
  content_lines: string[];
20
20
  hash_bounds: [RAnchor, RAnchor];
21
+ content_separators?: (LineEnding | undefined)[];
21
22
  };
22
23
 
23
24
  interface HMismatch {
@@ -288,6 +289,7 @@ export function valEdit(
288
289
  resolved: {
289
290
  content_lines: edit.content_lines,
290
291
  hash_bounds: [startResolved, endResolved],
292
+ ...(edit.content_separators !== undefined ? { content_separators: edit.content_separators } : {}),
291
293
  },
292
294
  mismatches,
293
295
  };
package/src/insert.ts CHANGED
@@ -125,13 +125,14 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
125
125
  allocation: "shadow",
126
126
  signal,
127
127
  });
128
- const { editParams, anchorLine } = buildInsertEdit(normalized, preload, ref, targetPath);
128
+ const { editParams, anchorLine, contentSeparators } = buildInsertEdit(normalized, preload, ref, targetPath);
129
129
  const pipe = await execPipeline(targetPath, editParams, cwd, {
130
130
  accessMode: constants.R_OK,
131
131
  noPersist: true,
132
132
  preloadedNorm: preload,
133
133
  signal,
134
134
  stripWarning: insertStripWarning(anchorLine, normalized.direction),
135
+ endingOverrides: contentSeparators,
135
136
  });
136
137
  return previewFromPipe(pipe);
137
138
  } catch (error: unknown) {
@@ -253,6 +254,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
253
254
  signal,
254
255
  preloadedNorm: preload,
255
256
  stripWarning: insertStripWarning(anchorLine, req.direction),
257
+ endingOverrides: contentSeparators,
256
258
  });
257
259
  return commitEdit(pipe, {
258
260
  path: pipe.path,
package/src/replace.ts CHANGED
@@ -83,6 +83,7 @@ export interface ExecPipelineOptions {
83
83
  served?: ReadonlyMap<string, string>;
84
84
  allowEmpty?: boolean;
85
85
  stripWarning?: StripWarningLocation;
86
+ endingOverrides?: (LineEnding | undefined)[];
86
87
  }
87
88
 
88
89
  export function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
@@ -134,6 +135,17 @@ export function buildReplaceHEdit(params: ReqParams): { edit: HEdit; warnings: s
134
135
  return { edit, warnings: editWarnings };
135
136
  }
136
137
 
138
+ function withEndingOverrides(edit: HEdit, overrides: (LineEnding | undefined)[] | undefined): HEdit {
139
+ if (overrides === undefined) return edit;
140
+ const length = Math.max(edit.content_separators?.length ?? 0, overrides.length);
141
+ const separators: (LineEnding | undefined)[] = new Array(length);
142
+ for (let index = 0; index < length; index++) {
143
+ separators[index] = overrides[index] ?? edit.content_separators?.[index];
144
+ }
145
+ if (separators.every((ending) => ending === undefined)) return edit;
146
+ return { ...edit, content_separators: separators };
147
+ }
148
+
137
149
  export async function execPipeline(
138
150
  targetPath: string,
139
151
  params: ReqParams,
@@ -142,6 +154,7 @@ export async function execPipeline(
142
154
  ): Promise<PipelineResult> {
143
155
 
144
156
  const { edit, warnings: editWarnings } = buildReplaceHEdit(params);
157
+ const anchoredEdit = withEndingOverrides(edit, options?.endingOverrides);
145
158
  const hashStore = options?.store ?? await loadHashStore();
146
159
  const preResolvedPath = await resolveTarget(toCwd(targetPath, cwd));
147
160
  const served = options?.served ?? servedForPath(preResolvedPath);
@@ -154,7 +167,7 @@ export async function execPipeline(
154
167
  try {
155
168
  anchorResult = applyEdit(
156
169
  originalNormalized,
157
- edit,
170
+ anchoredEdit,
158
171
  options?.signal,
159
172
  originalHashes,
160
173
  displayPath,
@@ -202,12 +215,15 @@ export async function execPipeline(
202
215
  totalRemovedLines,
203
216
  identity,
204
217
  ...(pipeSpans ? { spans: pipeSpans } : {}),
205
- ...(edit.content_separators !== undefined ? { contentSeparators: edit.content_separators } : {}),
218
+ ...(anchoredEdit.content_separators !== undefined ? { contentSeparators: anchoredEdit.content_separators } : {}),
206
219
  };
207
220
  }
208
221
 
209
222
  export function previewFromPipe(pipe: PipelineResult): RPreview {
210
223
  if (pipe.originalNormalized === pipe.result) {
224
+ if (pipe.contentSeparators !== undefined) {
225
+ return { diff: "", path: pipe.path };
226
+ }
211
227
  return {
212
228
  error: `No changes made to ${pipe.path}. The edit produced identical content.`,
213
229
  path: pipe.path,