@oh-my-pi/pi-coding-agent 14.3.0 → 14.4.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.
Files changed (117) hide show
  1. package/CHANGELOG.md +84 -1
  2. package/package.json +7 -7
  3. package/src/autoresearch/prompt.md +1 -1
  4. package/src/commit/agentic/prompts/analyze-file.md +1 -1
  5. package/src/config/model-registry.ts +67 -15
  6. package/src/config/prompt-templates.ts +5 -5
  7. package/src/config/settings-schema.ts +4 -4
  8. package/src/cursor.ts +3 -8
  9. package/src/discovery/helpers.ts +3 -3
  10. package/src/edit/diff.ts +50 -47
  11. package/src/edit/index.ts +86 -57
  12. package/src/edit/line-hash.ts +735 -19
  13. package/src/edit/modes/apply-patch.ts +0 -9
  14. package/src/edit/modes/atom.ts +658 -0
  15. package/src/edit/modes/chunk.ts +14 -24
  16. package/src/edit/modes/hashline.ts +188 -136
  17. package/src/edit/modes/patch.ts +5 -9
  18. package/src/edit/modes/replace.ts +6 -11
  19. package/src/edit/renderer.ts +14 -10
  20. package/src/edit/streaming.ts +50 -16
  21. package/src/exec/bash-executor.ts +2 -4
  22. package/src/export/html/template.generated.ts +1 -1
  23. package/src/export/html/template.js +4 -12
  24. package/src/extensibility/custom-tools/types.ts +2 -0
  25. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  26. package/src/internal-urls/docs-index.generated.ts +2 -2
  27. package/src/lsp/index.ts +1 -1
  28. package/src/mcp/render.ts +1 -8
  29. package/src/modes/components/assistant-message.ts +4 -0
  30. package/src/modes/components/diff.ts +23 -14
  31. package/src/modes/components/footer.ts +21 -16
  32. package/src/modes/components/settings-defs.ts +6 -1
  33. package/src/modes/components/todo-reminder.ts +1 -8
  34. package/src/modes/components/tool-execution.ts +1 -4
  35. package/src/modes/controllers/selector-controller.ts +1 -1
  36. package/src/modes/print-mode.ts +8 -0
  37. package/src/prompts/agents/librarian.md +1 -1
  38. package/src/prompts/agents/reviewer.md +4 -4
  39. package/src/prompts/ci-green-request.md +1 -1
  40. package/src/prompts/review-request.md +1 -1
  41. package/src/prompts/system/subagent-system-prompt.md +3 -3
  42. package/src/prompts/system/subagent-yield-reminder.md +11 -0
  43. package/src/prompts/system/system-prompt.md +3 -0
  44. package/src/prompts/tools/ask.md +3 -2
  45. package/src/prompts/tools/ast-edit.md +15 -19
  46. package/src/prompts/tools/ast-grep.md +18 -24
  47. package/src/prompts/tools/atom.md +96 -0
  48. package/src/prompts/tools/chunk-edit.md +37 -161
  49. package/src/prompts/tools/debug.md +4 -5
  50. package/src/prompts/tools/exit-plan-mode.md +4 -5
  51. package/src/prompts/tools/find.md +4 -8
  52. package/src/prompts/tools/github.md +18 -0
  53. package/src/prompts/tools/grep.md +4 -5
  54. package/src/prompts/tools/hashline.md +22 -89
  55. package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
  56. package/src/prompts/tools/inspect-image.md +6 -6
  57. package/src/prompts/tools/lsp.md +1 -1
  58. package/src/prompts/tools/patch.md +12 -19
  59. package/src/prompts/tools/python.md +3 -2
  60. package/src/prompts/tools/read-chunk.md +2 -3
  61. package/src/prompts/tools/read.md +2 -2
  62. package/src/prompts/tools/ssh.md +8 -17
  63. package/src/prompts/tools/todo-write.md +54 -41
  64. package/src/sdk.ts +14 -9
  65. package/src/session/agent-session.ts +25 -2
  66. package/src/task/executor.ts +43 -48
  67. package/src/task/render.ts +11 -13
  68. package/src/tools/ask.ts +7 -7
  69. package/src/tools/ast-edit.ts +45 -41
  70. package/src/tools/ast-grep.ts +77 -85
  71. package/src/tools/bash.ts +8 -9
  72. package/src/tools/browser.ts +32 -30
  73. package/src/tools/calculator.ts +4 -4
  74. package/src/tools/cancel-job.ts +1 -1
  75. package/src/tools/checkpoint.ts +2 -2
  76. package/src/tools/debug.ts +41 -37
  77. package/src/tools/exit-plan-mode.ts +1 -1
  78. package/src/tools/find.ts +4 -4
  79. package/src/tools/gh-renderer.ts +12 -4
  80. package/src/tools/gh.ts +509 -697
  81. package/src/tools/grep.ts +115 -130
  82. package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
  83. package/src/tools/index.ts +14 -32
  84. package/src/tools/inspect-image.ts +3 -3
  85. package/src/tools/json-tree.ts +114 -114
  86. package/src/tools/match-line-format.ts +9 -8
  87. package/src/tools/notebook.ts +8 -7
  88. package/src/tools/poll-tool.ts +2 -1
  89. package/src/tools/python.ts +9 -23
  90. package/src/tools/read.ts +32 -21
  91. package/src/tools/render-mermaid.ts +1 -1
  92. package/src/tools/render-utils.ts +18 -0
  93. package/src/tools/renderers.ts +2 -2
  94. package/src/tools/report-tool-issue.ts +3 -2
  95. package/src/tools/resolve.ts +1 -1
  96. package/src/tools/review.ts +12 -10
  97. package/src/tools/search-tool-bm25.ts +2 -4
  98. package/src/tools/ssh.ts +4 -4
  99. package/src/tools/todo-write.ts +172 -147
  100. package/src/tools/vim.ts +14 -15
  101. package/src/tools/write.ts +4 -4
  102. package/src/tools/{submit-result.ts → yield.ts} +11 -13
  103. package/src/utils/edit-mode.ts +2 -1
  104. package/src/utils/file-display-mode.ts +10 -5
  105. package/src/utils/git.ts +9 -5
  106. package/src/utils/shell-snapshot.ts +2 -3
  107. package/src/vim/render.ts +4 -4
  108. package/src/prompts/system/subagent-submit-reminder.md +0 -11
  109. package/src/prompts/tools/gh-issue-view.md +0 -11
  110. package/src/prompts/tools/gh-pr-checkout.md +0 -12
  111. package/src/prompts/tools/gh-pr-diff.md +0 -12
  112. package/src/prompts/tools/gh-pr-push.md +0 -12
  113. package/src/prompts/tools/gh-pr-view.md +0 -11
  114. package/src/prompts/tools/gh-repo-view.md +0 -11
  115. package/src/prompts/tools/gh-run-watch.md +0 -12
  116. package/src/prompts/tools/gh-search-issues.md +0 -11
  117. package/src/prompts/tools/gh-search-prs.md +0 -11
@@ -2,18 +2,19 @@
2
2
  * Hashline edit mode — a line-addressable edit format using text hashes.
3
3
  *
4
4
  * Each line in a file is identified by its 1-indexed line number and a short
5
- * hexadecimal hash derived from the normalized line text (xxHash32, truncated to 2
6
- * hex chars).
7
- * The combined `LINE#ID` reference acts as both an address and a staleness check:
5
+ * BPE-bigram hash derived from the normalized line text (xxHash32 mod 647,
6
+ * mapped through HASHLINE_BIGRAMS).
7
+ * The combined `LINE+ID` reference acts as both an address and a staleness check:
8
8
  * if the file has changed since the caller last read it, hash mismatches are caught
9
9
  * before any mutation occurs.
10
10
  *
11
- * Displayed format: `LINENUM#HASH:TEXT`
12
- * Reference format: `"LINENUM#HASH"` (e.g. `"5#aa"`)
11
+ * Displayed format: `LINE+ID:TEXT`
12
+ * Reference format: `"LINE+ID"` (e.g. `"1ab"`)
13
+ *
14
+ * In tool JSON, each edit's `content` is `string[]` (one string per logical line) or
15
+ * `null` to delete the targeted range.
13
16
  */
14
17
 
15
- import * as fs from "node:fs/promises";
16
- import * as nodePath from "node:path";
17
18
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
18
19
  import { isEnoent } from "@oh-my-pi/pi-utils";
19
20
  import { type Static, Type } from "@sinclair/typebox";
@@ -21,16 +22,13 @@ import type { BunFile } from "bun";
21
22
  import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
22
23
  import type { ToolSession } from "../../tools";
23
24
  import { assertEditableFileContent } from "../../tools/auto-generated-guard";
24
- import {
25
- invalidateFsScanAfterDelete,
26
- invalidateFsScanAfterRename,
27
- invalidateFsScanAfterWrite,
28
- } from "../../tools/fs-cache-invalidation";
25
+ import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
29
26
  import { outputMeta } from "../../tools/output-meta";
30
27
  import { resolveToCwd } from "../../tools/path-utils";
31
28
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
29
+ import { formatCodeFrameLine } from "../../tools/render-utils";
32
30
  import { generateDiffString } from "../diff";
33
- import { computeLineHash, formatLineHash } from "../line-hash";
31
+ import { computeLineHash, formatLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
34
32
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
35
33
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
36
34
 
@@ -49,8 +47,16 @@ export type HashlineEdit =
49
47
  | { op: "append_file"; lines: string[] }
50
48
  | { op: "prepend_file"; lines: string[] };
51
49
 
52
- const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/;
53
- const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/;
50
+ // Tight prefix matchers for the new format `LINE+ID:content`. Hard
51
+ // cutover do not accept legacy `LINENUM#BIGRAM:content` or tab separators.
52
+ // The terminator must be a literal colon; line-number digits are mandatory.
53
+ const HASHLINE_CONTENT_SEPARATOR_RE = HASHLINE_CONTENT_SEPARATOR.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
+ const HASHLINE_PREFIX_RE = new RegExp(
55
+ `^\\s*(?:>>>|>>)?\\s*(?:\\+\\s*)?\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
56
+ );
57
+ const HASHLINE_PREFIX_PLUS_RE = new RegExp(
58
+ `^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
59
+ );
54
60
  const DIFF_PLUS_RE = /^[+](?![+])/;
55
61
  const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L\d+/;
56
62
 
@@ -129,11 +135,7 @@ export function stripHashlinePrefixes(lines: string[]): string[] {
129
135
  return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
130
136
  }
131
137
 
132
- const linesSchema = Type.Union([
133
- Type.Array(Type.String(), { description: "content (preferred format)" }),
134
- Type.String(),
135
- Type.Null(),
136
- ]);
138
+ const linesSchema = Type.Union([Type.Array(Type.String()), Type.Null()]);
137
139
 
138
140
  const locSchema = Type.Union(
139
141
  [
@@ -153,17 +155,16 @@ const locSchema = Type.Union(
153
155
 
154
156
  export const hashlineEditSchema = Type.Object(
155
157
  {
156
- path: Type.String({ description: "File path" }),
158
+ path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
157
159
  loc: Type.Optional(locSchema),
158
160
  content: Type.Optional(linesSchema),
159
- delete: Type.Optional(Type.Boolean({ description: "Delete the file" })),
160
- move: Type.Optional(Type.String({ description: "Move/rename the file to this path" })),
161
161
  },
162
162
  { additionalProperties: false },
163
163
  );
164
164
 
165
165
  export const hashlineEditParamsSchema = Type.Object(
166
166
  {
167
+ path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
167
168
  edits: Type.Array(hashlineEditSchema, { description: "edits" }),
168
169
  },
169
170
  { additionalProperties: false },
@@ -182,6 +183,11 @@ export interface ExecuteHashlineSingleOptions {
182
183
  beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
183
184
  }
184
185
 
186
+ /**
187
+ * Normalize line payloads for apply: strip read/grep line prefixes. The tool schema
188
+ * supplies `string[]` (one element per line). `null` / `undefined` yield `[]`.
189
+ * A single multiline `string` is still split on `\n` for the same normalization path.
190
+ */
185
191
  export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
186
192
  if (edit == null) return [];
187
193
  if (typeof edit === "string") {
@@ -191,15 +197,6 @@ export function hashlineParseText(edit: string[] | string | null | undefined): s
191
197
  return stripNewLinePrefixes(edit);
192
198
  }
193
199
 
194
- export function isHashlineParams(params: unknown): params is HashlineParams {
195
- if (typeof params !== "object" || params === null || !("edits" in params) || !Array.isArray(params.edits))
196
- return false;
197
- if (params.edits.length === 0) return true;
198
- const first = params.edits[0];
199
- if (typeof first !== "object" || first === null) return false;
200
- return "loc" in first || "delete" in first || "move" in first;
201
- }
202
-
203
200
  function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
204
201
  return edits.map(resolveEditAnchor);
205
202
  }
@@ -224,6 +221,15 @@ function resolveHashlineEditsForDiff(edits: HashlineEditInput[]): HashlineEdit[]
224
221
  });
225
222
  }
226
223
 
224
+ export function formatFullAnchorRequirement(raw?: string): string {
225
+ const suffix = typeof raw === "string" ? raw.trim() : "";
226
+ const hashOnlyHint = /^[A-Za-z]{2}$/.test(suffix)
227
+ ? ` It looks like you supplied only the 2-letter suffix (${JSON.stringify(suffix)}). Copy the full anchor exactly as shown (for example, "160${suffix}").`
228
+ : "";
229
+ const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
230
+ return `the full anchor exactly as shown by read/grep (line number + 2-letter suffix, for example "160sr")${received}${hashOnlyHint}`;
231
+ }
232
+
227
233
  function tryParseTag(raw: string): Anchor | undefined {
228
234
  try {
229
235
  return parseTag(raw);
@@ -234,14 +240,24 @@ function tryParseTag(raw: string): Anchor | undefined {
234
240
 
235
241
  function requireParsedAnchor(raw: string, op: "append" | "prepend"): Anchor {
236
242
  const anchor = tryParseTag(raw);
237
- if (!anchor) throw new Error(`${op} requires a valid anchor.`);
243
+ if (!anchor) throw new Error(`${op} requires ${formatFullAnchorRequirement(raw)}.`);
238
244
  return anchor;
239
245
  }
240
246
 
241
247
  function requireParsedRange(range: { pos: string; end: string }): { pos: Anchor; end: Anchor } {
242
248
  const pos = tryParseTag(range.pos);
243
249
  const end = tryParseTag(range.end);
244
- if (!pos || !end) throw new Error("range requires valid pos and end anchors.");
250
+ if (!pos || !end) {
251
+ const invalid = [
252
+ !pos ? `pos=${JSON.stringify(range.pos)}` : null,
253
+ !end ? `end=${JSON.stringify(range.end)}` : null,
254
+ ]
255
+ .filter(Boolean)
256
+ .join(", ");
257
+ throw new Error(
258
+ `range requires valid pos and end anchors. Use ${formatFullAnchorRequirement()}. Invalid: ${invalid}.`,
259
+ );
260
+ }
245
261
  return { pos, end };
246
262
  }
247
263
 
@@ -358,7 +374,7 @@ function createHashlineChunkEmitter(
358
374
  }
359
375
 
360
376
  function formatHashlineStreamLine(lineNumber: number, line: string): string {
361
- return `${formatLineHash(lineNumber, line)}:${line}`;
377
+ return `${formatLineHash(lineNumber, line)}${HASHLINE_CONTENT_SEPARATOR}${line}`;
362
378
  }
363
379
 
364
380
  function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
@@ -484,20 +500,18 @@ export async function* streamHashLinesFromLines(
484
500
  }
485
501
 
486
502
  /**
487
- * Parse a line reference string like `"5#abcd"` into structured form.
503
+ * Parse a line reference string like `"5th"` into structured form.
488
504
  *
489
- * @throws Error if the format is invalid (not `NUMBER#HEXHASH`)
505
+ * @throws Error if the format is invalid (not `NUMBERBIGRAM`)
490
506
  */
491
507
  export function parseTag(ref: string): { line: number; hash: string } {
492
- // This regex captures:
493
- // 1. optional leading ">+" and whitespace
508
+ // Captures:
509
+ // 1. optional leading ">+-" markers and whitespace
494
510
  // 2. line number (1+ digits)
495
- // 3. "#" with optional surrounding spaces
496
- // 4. hash (2 hex chars)
497
- // 5. optional trailing display suffix (":..." or " ...")
498
- const match = ref.match(/^\s*[>+-]*\s*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})/);
511
+ // 3. hash (one BPE bigram from HASHLINE_BIGRAMS) directly adjacent (no separator)
512
+ const match = ref.match(new RegExp(`^\\s*[>+-]*\\s*(\\d+)(${HASHLINE_BIGRAM_RE_SRC})`));
499
513
  if (!match) {
500
- throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`);
514
+ throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
501
515
  }
502
516
  const line = Number.parseInt(match[1], 10);
503
517
  if (line < 1) {
@@ -516,8 +530,8 @@ const MISMATCH_CONTEXT = 2;
516
530
  /**
517
531
  * Error thrown when one or more hashline references have stale hashes.
518
532
  *
519
- * Displays grep-style output with `>>>` markers on mismatched lines,
520
- * showing the correct `LINE#ID` so the caller can fix all refs at once.
533
+ * Displays grep-style output with `:` separator on mismatched lines and `-` on
534
+ * surrounding context, showing the correct `LINE+ID` so the caller can fix all refs at once.
521
535
  */
522
536
  export class HashlineMismatchError extends Error {
523
537
  readonly remaps: ReadonlyMap<string, string>;
@@ -530,11 +544,50 @@ export class HashlineMismatchError extends Error {
530
544
  const remaps = new Map<string, string>();
531
545
  for (const m of mismatches) {
532
546
  const actual = computeLineHash(m.line, fileLines[m.line - 1]);
533
- remaps.set(`${m.line}#${m.expected}`, `${m.line}#${actual}`);
547
+ remaps.set(`${m.line}${m.expected}`, `${m.line}${actual}`);
534
548
  }
535
549
  this.remaps = remaps;
536
550
  }
537
551
 
552
+ /**
553
+ * User-visible variant of {@link formatMessage} — omits the bigram fingerprint
554
+ * and uses a `│` gutter so TUI rendering is clean. The model still receives
555
+ * the full `LINE+ID:content` form via {@link Error.message}.
556
+ */
557
+ get displayMessage(): string {
558
+ return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
559
+ }
560
+
561
+ static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
562
+ const mismatchSet = new Set<number>();
563
+ for (const m of mismatches) mismatchSet.add(m.line);
564
+
565
+ const displayLines = new Set<number>();
566
+ for (const m of mismatches) {
567
+ const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
568
+ const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT);
569
+ for (let i = lo; i <= hi; i++) displayLines.add(i);
570
+ }
571
+
572
+ const sorted = [...displayLines].sort((a, b) => a - b);
573
+ const out: string[] = [
574
+ `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
575
+ "Realign your edit to the file state shown below. Copy the full anchors exactly as shown (for example `160sr`, not just `sr`).",
576
+ "",
577
+ ];
578
+
579
+ const lineNumberWidth = sorted.reduce((width, lineNum) => Math.max(width, String(lineNum).length), 0);
580
+ let prevLine = -1;
581
+ for (const lineNum of sorted) {
582
+ if (prevLine !== -1 && lineNum > prevLine + 1) out.push("...");
583
+ prevLine = lineNum;
584
+ const text = fileLines[lineNum - 1];
585
+ const marker = mismatchSet.has(lineNum) ? "*" : " ";
586
+ out.push(formatCodeFrameLine(marker, lineNum, text ?? "", lineNumberWidth));
587
+ }
588
+ return out.join("\n");
589
+ }
590
+
538
591
  static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
539
592
  const mismatchSet = new Map<number, HashMismatch>();
540
593
  for (const m of mismatches) {
@@ -555,7 +608,8 @@ export class HashlineMismatchError extends Error {
555
608
  const lines: string[] = [];
556
609
 
557
610
  lines.push(
558
- `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied. Use the updated LINE#ID references shown below (>>> marks changed lines) and retry the edit.`,
611
+ `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
612
+ "Use the updated anchors shown below (`:` marks changed lines, `-` marks context) and retry the edit.",
559
613
  );
560
614
  lines.push("");
561
615
 
@@ -563,18 +617,18 @@ export class HashlineMismatchError extends Error {
563
617
  for (const lineNum of sorted) {
564
618
  // Gap separator between non-contiguous regions
565
619
  if (prevLine !== -1 && lineNum > prevLine + 1) {
566
- lines.push(" ...");
620
+ lines.push("...");
567
621
  }
568
622
  prevLine = lineNum;
569
623
 
570
624
  const text = fileLines[lineNum - 1];
571
625
  const hash = computeLineHash(lineNum, text);
572
- const prefix = `${lineNum}#${hash}`;
626
+ const prefix = `${lineNum}${hash}`;
573
627
 
574
628
  if (mismatchSet.has(lineNum)) {
575
- lines.push(`>>> ${prefix}:${text}`);
629
+ lines.push(`${prefix}:${text}`);
576
630
  } else {
577
- lines.push(` ${prefix}:${text}`);
631
+ lines.push(`${prefix}-${text}`);
578
632
  }
579
633
  }
580
634
  return lines.join("\n");
@@ -599,6 +653,39 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
599
653
  }
600
654
  }
601
655
 
656
+ /**
657
+ * Default search window for {@link tryRebaseAnchor} (lines on each side of the requested anchor).
658
+ */
659
+ export const ANCHOR_REBASE_WINDOW = 2;
660
+
661
+ /**
662
+ * Look for the requested hash within ±`window` lines of `anchor.line`.
663
+ *
664
+ * Returns the new line number when exactly one nearby line matches the hash;
665
+ * otherwise `null` (genuine mismatch or ambiguous). The caller is expected to
666
+ * mutate `anchor.line` in place and surface a warning so the model knows the
667
+ * edit was retargeted.
668
+ *
669
+ * The exact-position match (anchor.line itself) is intentionally skipped: the
670
+ * caller has already determined the requested line's hash does not match.
671
+ */
672
+ export function tryRebaseAnchor(
673
+ anchor: { line: number; hash: string },
674
+ fileLines: string[],
675
+ window: number = ANCHOR_REBASE_WINDOW,
676
+ ): number | null {
677
+ const lo = Math.max(1, anchor.line - window);
678
+ const hi = Math.min(fileLines.length, anchor.line + window);
679
+ let found: number | null = null;
680
+ for (let line = lo; line <= hi; line++) {
681
+ if (line === anchor.line) continue;
682
+ if (computeLineHash(line, fileLines[line - 1]) !== anchor.hash) continue;
683
+ if (found !== null) return null; // ambiguous: more than one match in window
684
+ found = line;
685
+ }
686
+ return found;
687
+ }
688
+
602
689
  function isEscapedTabAutocorrectEnabled(): boolean {
603
690
  switch (Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS) {
604
691
  case "0":
@@ -754,7 +841,7 @@ function applyHashlineEditToLines(
754
841
  if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
755
842
  noopEdits.push({
756
843
  editIndex,
757
- loc: `${edit.pos.line}#${edit.pos.hash}`,
844
+ loc: `${edit.pos.line}${edit.pos.hash}`,
758
845
  current: origLines.join("\n"),
759
846
  });
760
847
  break;
@@ -765,6 +852,15 @@ function applyHashlineEditToLines(
765
852
  }
766
853
  case "replace_range": {
767
854
  const count = edit.end.line - edit.pos.line + 1;
855
+ const origRange = originalFileLines.slice(edit.pos.line - 1, edit.pos.line - 1 + count);
856
+ if (count === edit.lines.length && origRange.every((line, i) => line === edit.lines[i])) {
857
+ noopEdits.push({
858
+ editIndex,
859
+ loc: `${edit.pos.line}${edit.pos.hash}-${edit.end.line}${edit.end.hash}`,
860
+ current: origRange.join("\n"),
861
+ });
862
+ break;
863
+ }
768
864
  fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
769
865
  trackFirstChanged(edit.pos.line);
770
866
  break;
@@ -774,7 +870,7 @@ function applyHashlineEditToLines(
774
870
  if (inserted.length === 0) {
775
871
  noopEdits.push({
776
872
  editIndex,
777
- loc: `${edit.pos.line}#${edit.pos.hash}`,
873
+ loc: `${edit.pos.line}${edit.pos.hash}`,
778
874
  current: originalFileLines[edit.pos.line - 1],
779
875
  });
780
876
  break;
@@ -788,7 +884,7 @@ function applyHashlineEditToLines(
788
884
  if (inserted.length === 0) {
789
885
  noopEdits.push({
790
886
  editIndex,
791
- loc: `${edit.pos.line}#${edit.pos.hash}`,
887
+ loc: `${edit.pos.line}${edit.pos.hash}`,
792
888
  current: originalFileLines[edit.pos.line - 1],
793
889
  });
794
890
  break;
@@ -849,7 +945,7 @@ function buildHashlineEditResult(params: {
849
945
  };
850
946
  }
851
947
 
852
- function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] {
948
+ function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
853
949
  const mismatches: HashMismatch[] = [];
854
950
  for (const edit of edits) {
855
951
  switch (edit.op) {
@@ -884,6 +980,15 @@ function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): H
884
980
  if (actualHash === ref.hash) {
885
981
  return;
886
982
  }
983
+ const rebased = tryRebaseAnchor(ref, fileLines);
984
+ if (rebased !== null) {
985
+ const original = `${ref.line}${ref.hash}`;
986
+ ref.line = rebased;
987
+ warnings.push(
988
+ `Auto-rebased anchor ${original} → ${rebased}${ref.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
989
+ );
990
+ return;
991
+ }
887
992
  mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
888
993
  }
889
994
  }
@@ -922,7 +1027,7 @@ export function applyHashlineEdits(
922
1027
  const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = [];
923
1028
  const warnings: string[] = [];
924
1029
 
925
- const mismatches = validateHashlineEditRefs(edits, fileLines);
1030
+ const mismatches = validateHashlineEditRefs(edits, fileLines, warnings);
926
1031
  if (mismatches.length > 0) {
927
1032
  throw new HashlineMismatchError(mismatches, fileLines);
928
1033
  }
@@ -1022,14 +1127,12 @@ function syncNewLineCounters(counters: CompactPreviewCounters, lineNumber: numbe
1022
1127
  counters.newLine = lineNumber;
1023
1128
  }
1024
1129
 
1025
- function formatCompactHashlineLine(kind: " " | "+", lineNumber: number, width: number, content: string): string {
1026
- const padded = String(lineNumber).padStart(width, " ");
1027
- return `${kind}${padded}#${computeLineHash(lineNumber, content)}|${content}`;
1130
+ function formatCompactHashlineLine(kind: " " | "+", lineNumber: number, content: string): string {
1131
+ return `${kind}${lineNumber}${computeLineHash(lineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
1028
1132
  }
1029
1133
 
1030
- function formatCompactRemovedLine(lineNumber: number, width: number, content: string): string {
1031
- const padded = String(lineNumber).padStart(width, " ");
1032
- return `-${padded}${HASHLINE_PREVIEW_PLACEHOLDER}|${content}`;
1134
+ function formatCompactRemovedLine(lineNumber: number, content: string): string {
1135
+ return `-${lineNumber}${HASHLINE_PREVIEW_PLACEHOLDER}${HASHLINE_CONTENT_SEPARATOR}${content}`;
1033
1136
  }
1034
1137
 
1035
1138
  function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters): { kind: DiffRunKind; text: string } {
@@ -1050,13 +1153,13 @@ function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters
1050
1153
  syncNewLineCounters(counters, parsed.lineNumber);
1051
1154
  const newLine = counters.newLine;
1052
1155
  if (newLine === undefined) return { kind: "+", text: parsed.raw };
1053
- const text = formatCompactHashlineLine("+", newLine, parsed.lineWidth, parsed.content);
1156
+ const text = formatCompactHashlineLine("+", newLine, parsed.content);
1054
1157
  counters.newLine = newLine + 1;
1055
1158
  return { kind: "+", text };
1056
1159
  }
1057
1160
  case "-": {
1058
1161
  syncOldLineCounters(counters, parsed.lineNumber);
1059
- const text = formatCompactRemovedLine(parsed.lineNumber, parsed.lineWidth, parsed.content);
1162
+ const text = formatCompactRemovedLine(parsed.lineNumber, parsed.content);
1060
1163
  counters.oldLine = parsed.lineNumber + 1;
1061
1164
  return { kind: "-", text };
1062
1165
  }
@@ -1064,7 +1167,7 @@ function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters
1064
1167
  syncOldLineCounters(counters, parsed.lineNumber);
1065
1168
  const newLine = counters.newLine;
1066
1169
  if (newLine === undefined) return { kind: " ", text: parsed.raw };
1067
- const text = formatCompactHashlineLine(" ", newLine, parsed.lineWidth, parsed.content);
1170
+ const text = formatCompactHashlineLine(" ", newLine, parsed.content);
1068
1171
  counters.oldLine = parsed.lineNumber + 1;
1069
1172
  counters.newLine = newLine + 1;
1070
1173
  return { kind: " ", text };
@@ -1170,7 +1273,7 @@ export function buildCompactHashlineDiffPreview(
1170
1273
  }
1171
1274
 
1172
1275
  export async function computeHashlineDiff(
1173
- input: { path: string; edits: HashlineEditInput[]; move?: string },
1276
+ input: { path: string; edits: HashlineEditInput[] },
1174
1277
  cwd: string,
1175
1278
  ): Promise<
1176
1279
  | {
@@ -1181,28 +1284,19 @@ export async function computeHashlineDiff(
1181
1284
  error: string;
1182
1285
  }
1183
1286
  > {
1184
- const { path, edits, move } = input;
1287
+ const { path, edits } = input;
1185
1288
 
1186
1289
  try {
1187
1290
  const absolutePath = resolveToCwd(path, cwd);
1188
- const movePath = move ? resolveToCwd(move, cwd) : undefined;
1189
- const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
1190
1291
  const resolvedEdits = resolveHashlineEditsForDiff(edits);
1191
1292
  const file = Bun.file(absolutePath);
1192
1293
 
1193
- if (movePath === absolutePath) {
1194
- return { error: "move path is the same as source path" };
1195
- }
1196
- if (isMoveOnly) {
1197
- return { diff: "", firstChangedLine: undefined };
1198
- }
1199
-
1200
1294
  const rawContent = await readHashlineFileText(file, path);
1201
1295
 
1202
1296
  const { text: content } = stripBom(rawContent);
1203
1297
  const normalizedContent = normalizeToLF(content);
1204
1298
  const result = applyHashlineEdits(normalizedContent, resolvedEdits);
1205
- if (normalizedContent === result.lines && !move) {
1299
+ if (normalizedContent === result.lines) {
1206
1300
  return { error: `No changes would be made to ${path}. The edits produce identical content.` };
1207
1301
  }
1208
1302
 
@@ -1229,63 +1323,18 @@ export async function executeHashlineSingle(
1229
1323
  ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1230
1324
  const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
1231
1325
 
1232
- // Extract file-level ops from edits
1233
- const deleteFile = edits.some(e => e.delete);
1234
- const move = edits.find(e => e.move)?.move;
1235
- // Filter to content edits only (those with loc)
1236
1326
  const contentEdits = edits.filter(e => e.loc != null);
1237
1327
 
1238
- enforcePlanModeWrite(session, path, { op: deleteFile ? "delete" : "update", move });
1328
+ enforcePlanModeWrite(session, path, { op: "update" });
1239
1329
 
1240
1330
  if (path.endsWith(".ipynb") && contentEdits.length > 0) {
1241
1331
  throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1242
1332
  }
1243
1333
 
1244
1334
  const absolutePath = resolvePlanPath(session, path);
1245
- const resolvedMove = move ? resolvePlanPath(session, move) : undefined;
1246
- if (resolvedMove === absolutePath) {
1247
- throw new Error("move path is the same as source path");
1248
- }
1249
1335
 
1250
1336
  const sourceFile = Bun.file(absolutePath);
1251
1337
  const sourceExists = await sourceFile.exists();
1252
- const isMoveOnly = Boolean(resolvedMove) && contentEdits.length === 0;
1253
-
1254
- if (deleteFile) {
1255
- if (sourceExists) {
1256
- await sourceFile.unlink();
1257
- }
1258
- invalidateFsScanAfterDelete(absolutePath);
1259
- return {
1260
- content: [{ type: "text", text: `Deleted ${path}` }],
1261
- details: {
1262
- diff: "",
1263
- op: "delete",
1264
- meta: outputMeta().get(),
1265
- },
1266
- };
1267
- }
1268
-
1269
- if (isMoveOnly && resolvedMove) {
1270
- if (!sourceExists) {
1271
- throw new Error(`File not found: ${path}`);
1272
- }
1273
- const parentDir = nodePath.dirname(resolvedMove);
1274
- if (parentDir && parentDir !== ".") {
1275
- await fs.mkdir(parentDir, { recursive: true });
1276
- }
1277
- await fs.rename(absolutePath, resolvedMove);
1278
- invalidateFsScanAfterRename(absolutePath, resolvedMove);
1279
- return {
1280
- content: [{ type: "text", text: `Moved ${path} to ${move}` }],
1281
- details: {
1282
- diff: "",
1283
- op: "update",
1284
- move,
1285
- meta: outputMeta().get(),
1286
- },
1287
- };
1288
- }
1289
1338
 
1290
1339
  if (!sourceExists) {
1291
1340
  const lines: string[] = [];
@@ -1329,7 +1378,7 @@ export async function executeHashlineSingle(
1329
1378
  warnings: anchorResult.warnings,
1330
1379
  noopEdits: anchorResult.noopEdits,
1331
1380
  };
1332
- if (originalNormalized === result.text && !move) {
1381
+ if (originalNormalized === result.text) {
1333
1382
  let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
1334
1383
  if (result.noopEdits && result.noopEdits.length > 0) {
1335
1384
  const details = result.noopEdits
@@ -1345,28 +1394,32 @@ export async function executeHashlineSingle(
1345
1394
  diagnostic += `\nThe file currently contains these lines:\n${preview}\nYour edits were normalized back to the original content (whitespace-only differences are preserved as-is). Ensure your replacement changes actual code, not just formatting.`;
1346
1395
  }
1347
1396
  }
1397
+ if (result.noopEdits.some(e => e.loc.includes("-"))) {
1398
+ diagnostic +=
1399
+ "\nHint: a `range` loc replaces the entire span inclusive of both endpoints. " +
1400
+ "If your replacement repeats the existing content, narrow the range or change the replacement.";
1401
+ }
1348
1402
  }
1349
1403
  throw new Error(diagnostic);
1350
1404
  }
1351
1405
 
1352
- const writePath = resolvedMove ?? absolutePath;
1353
1406
  const finalContent = bom + restoreLineEndings(result.text, originalEnding);
1354
- const diagnostics = await writethrough(writePath, finalContent, signal, Bun.file(writePath), batchRequest, dst =>
1355
- dst === writePath ? beginDeferredDiagnosticsForPath(writePath) : undefined,
1407
+ const diagnostics = await writethrough(
1408
+ absolutePath,
1409
+ finalContent,
1410
+ signal,
1411
+ Bun.file(absolutePath),
1412
+ batchRequest,
1413
+ dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
1356
1414
  );
1357
- if (resolvedMove && resolvedMove !== absolutePath) {
1358
- await sourceFile.unlink();
1359
- invalidateFsScanAfterRename(absolutePath, resolvedMove);
1360
- } else {
1361
- invalidateFsScanAfterWrite(absolutePath);
1362
- }
1415
+ invalidateFsScanAfterWrite(absolutePath);
1363
1416
 
1364
1417
  const diffResult = generateDiffString(originalNormalized, result.text);
1365
1418
  const meta = outputMeta()
1366
1419
  .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
1367
1420
  .get();
1368
1421
 
1369
- const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`;
1422
+ const resultText = `Updated ${path}`;
1370
1423
  const preview = buildCompactHashlineDiffPreview(diffResult.diff);
1371
1424
  const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`;
1372
1425
  const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
@@ -1384,7 +1437,6 @@ export async function executeHashlineSingle(
1384
1437
  firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
1385
1438
  diagnostics,
1386
1439
  op: "update",
1387
- move,
1388
1440
  meta,
1389
1441
  },
1390
1442
  };
@@ -1577,7 +1577,7 @@ export async function computePatchDiff(
1577
1577
  }
1578
1578
 
1579
1579
  export const patchEditEntrySchema = Type.Object({
1580
- path: Type.String({ description: "File path" }),
1580
+ path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
1581
1581
  op: Type.Optional(
1582
1582
  StringEnum(["create", "delete", "update"], {
1583
1583
  description: "Operation (default: update)",
@@ -1588,6 +1588,7 @@ export const patchEditEntrySchema = Type.Object({
1588
1588
  });
1589
1589
 
1590
1590
  export const patchEditSchema = Type.Object({
1591
+ path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
1591
1592
  edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }),
1592
1593
  });
1593
1594
 
@@ -1605,14 +1606,6 @@ export interface ExecutePatchSingleOptions {
1605
1606
  beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
1606
1607
  }
1607
1608
 
1608
- export function isPatchParams(params: unknown): params is PatchParams {
1609
- if (typeof params !== "object" || params === null) return false;
1610
- if (!("edits" in params) || !Array.isArray((params as any).edits)) return false;
1611
- const first = (params as any).edits[0];
1612
- if (!first || typeof first !== "object") return false;
1613
- return "path" in first && !("old_text" in first) && !("new_text" in first);
1614
- }
1615
-
1616
1609
  class LspFileSystem implements FileSystem {
1617
1610
  #lastDiagnostics: FileDiagnosticsResult | undefined;
1618
1611
  #fileCache: Record<string, Bun.BunFile> = {};
@@ -1710,6 +1703,9 @@ export async function executePatchSingle(
1710
1703
  beginDeferredDiagnosticsForPath,
1711
1704
  } = options;
1712
1705
  const { path, op: rawOp, rename, diff } = params;
1706
+ if (typeof path !== "string" || path.length === 0) {
1707
+ throw new Error("patch edit: missing `path`. Provide `path` on the edit or supply a top-level `path`.");
1708
+ }
1713
1709
 
1714
1710
  const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update";
1715
1711