@oh-my-pi/pi-coding-agent 14.3.0 → 14.4.1

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 (120) hide show
  1. package/CHANGELOG.md +98 -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 +743 -24
  13. package/src/edit/modes/apply-patch.ts +0 -9
  14. package/src/edit/modes/atom.ts +893 -0
  15. package/src/edit/modes/chunk.ts +14 -24
  16. package/src/edit/modes/hashline.ts +193 -146
  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/defaults.json +142 -652
  28. package/src/lsp/index.ts +1 -1
  29. package/src/mcp/render.ts +1 -8
  30. package/src/modes/components/assistant-message.ts +4 -0
  31. package/src/modes/components/diff.ts +23 -14
  32. package/src/modes/components/footer.ts +21 -16
  33. package/src/modes/components/session-selector.ts +3 -3
  34. package/src/modes/components/settings-defs.ts +6 -1
  35. package/src/modes/components/todo-reminder.ts +1 -8
  36. package/src/modes/components/tool-execution.ts +1 -4
  37. package/src/modes/controllers/selector-controller.ts +1 -1
  38. package/src/modes/print-mode.ts +8 -0
  39. package/src/prompts/agents/librarian.md +1 -1
  40. package/src/prompts/agents/reviewer.md +4 -4
  41. package/src/prompts/ci-green-request.md +1 -1
  42. package/src/prompts/review-request.md +1 -1
  43. package/src/prompts/system/subagent-system-prompt.md +3 -3
  44. package/src/prompts/system/subagent-yield-reminder.md +11 -0
  45. package/src/prompts/system/system-prompt.md +3 -0
  46. package/src/prompts/tools/ask.md +3 -2
  47. package/src/prompts/tools/ast-edit.md +16 -20
  48. package/src/prompts/tools/ast-grep.md +19 -24
  49. package/src/prompts/tools/atom.md +87 -0
  50. package/src/prompts/tools/chunk-edit.md +37 -161
  51. package/src/prompts/tools/debug.md +4 -5
  52. package/src/prompts/tools/exit-plan-mode.md +4 -5
  53. package/src/prompts/tools/find.md +4 -8
  54. package/src/prompts/tools/github.md +18 -0
  55. package/src/prompts/tools/grep.md +4 -5
  56. package/src/prompts/tools/hashline.md +22 -89
  57. package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
  58. package/src/prompts/tools/inspect-image.md +6 -6
  59. package/src/prompts/tools/lsp.md +1 -1
  60. package/src/prompts/tools/patch.md +12 -19
  61. package/src/prompts/tools/python.md +3 -2
  62. package/src/prompts/tools/read-chunk.md +2 -3
  63. package/src/prompts/tools/read.md +2 -2
  64. package/src/prompts/tools/ssh.md +8 -17
  65. package/src/prompts/tools/todo-write.md +54 -41
  66. package/src/sdk.ts +14 -9
  67. package/src/session/agent-session.ts +25 -2
  68. package/src/session/session-manager.ts +4 -1
  69. package/src/task/executor.ts +43 -48
  70. package/src/task/render.ts +11 -13
  71. package/src/tools/ask.ts +7 -7
  72. package/src/tools/ast-edit.ts +45 -41
  73. package/src/tools/ast-grep.ts +77 -85
  74. package/src/tools/bash.ts +8 -9
  75. package/src/tools/browser.ts +32 -30
  76. package/src/tools/calculator.ts +4 -4
  77. package/src/tools/cancel-job.ts +1 -1
  78. package/src/tools/checkpoint.ts +2 -2
  79. package/src/tools/debug.ts +41 -37
  80. package/src/tools/exit-plan-mode.ts +1 -1
  81. package/src/tools/find.ts +4 -4
  82. package/src/tools/gh-renderer.ts +12 -4
  83. package/src/tools/gh.ts +509 -697
  84. package/src/tools/grep.ts +116 -131
  85. package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
  86. package/src/tools/index.ts +14 -32
  87. package/src/tools/inspect-image.ts +3 -3
  88. package/src/tools/json-tree.ts +114 -114
  89. package/src/tools/match-line-format.ts +8 -7
  90. package/src/tools/notebook.ts +8 -7
  91. package/src/tools/poll-tool.ts +2 -1
  92. package/src/tools/python.ts +9 -23
  93. package/src/tools/read.ts +32 -25
  94. package/src/tools/render-mermaid.ts +1 -1
  95. package/src/tools/render-utils.ts +18 -0
  96. package/src/tools/renderers.ts +2 -2
  97. package/src/tools/report-tool-issue.ts +3 -2
  98. package/src/tools/resolve.ts +1 -1
  99. package/src/tools/review.ts +12 -10
  100. package/src/tools/search-tool-bm25.ts +2 -4
  101. package/src/tools/ssh.ts +4 -4
  102. package/src/tools/todo-write.ts +172 -147
  103. package/src/tools/vim.ts +14 -15
  104. package/src/tools/write.ts +4 -4
  105. package/src/tools/{submit-result.ts → yield.ts} +11 -13
  106. package/src/utils/edit-mode.ts +2 -1
  107. package/src/utils/file-display-mode.ts +10 -5
  108. package/src/utils/git.ts +9 -5
  109. package/src/utils/shell-snapshot.ts +2 -3
  110. package/src/vim/render.ts +4 -4
  111. package/src/prompts/system/subagent-submit-reminder.md +0 -11
  112. package/src/prompts/tools/gh-issue-view.md +0 -11
  113. package/src/prompts/tools/gh-pr-checkout.md +0 -12
  114. package/src/prompts/tools/gh-pr-diff.md +0 -12
  115. package/src/prompts/tools/gh-pr-push.md +0 -12
  116. package/src/prompts/tools/gh-pr-view.md +0 -11
  117. package/src/prompts/tools/gh-repo-view.md +0 -11
  118. package/src/prompts/tools/gh-run-watch.md +0 -12
  119. package/src/prompts/tools/gh-search-issues.md +0 -11
  120. 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, formatHashLine, 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
 
@@ -40,7 +38,7 @@ export interface HashMismatch {
40
38
  actual: string;
41
39
  }
42
40
 
43
- export type Anchor = { line: number; hash: string };
41
+ export type Anchor = { line: number; hash: string; contentHint?: string };
44
42
  export type HashlineEdit =
45
43
  | { op: "replace_line"; pos: Anchor; lines: string[] }
46
44
  | { op: "replace_range"; pos: Anchor; end: Anchor; lines: string[] }
@@ -49,8 +47,17 @@ 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`. The pipe is the
51
+ // canonical separator; legacy reads using `:` are tolerated for back-compat.
52
+ // Line-number digits are mandatory.
53
+ // Accept both `|` (canonical) and `:` (legacy) so re-reads of older outputs still parse.
54
+ const HASHLINE_CONTENT_SEPARATOR_RE = "[:|]";
55
+ const HASHLINE_PREFIX_RE = new RegExp(
56
+ `^\\s*(?:>>>|>>)?\\s*(?:\\+\\s*)?\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
57
+ );
58
+ const HASHLINE_PREFIX_PLUS_RE = new RegExp(
59
+ `^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
60
+ );
54
61
  const DIFF_PLUS_RE = /^[+](?![+])/;
55
62
  const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L\d+/;
56
63
 
@@ -129,11 +136,7 @@ export function stripHashlinePrefixes(lines: string[]): string[] {
129
136
  return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
130
137
  }
131
138
 
132
- const linesSchema = Type.Union([
133
- Type.Array(Type.String(), { description: "content (preferred format)" }),
134
- Type.String(),
135
- Type.Null(),
136
- ]);
139
+ const linesSchema = Type.Union([Type.Array(Type.String()), Type.Null()]);
137
140
 
138
141
  const locSchema = Type.Union(
139
142
  [
@@ -153,17 +156,16 @@ const locSchema = Type.Union(
153
156
 
154
157
  export const hashlineEditSchema = Type.Object(
155
158
  {
156
- path: Type.String({ description: "File path" }),
159
+ path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
157
160
  loc: Type.Optional(locSchema),
158
161
  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
162
  },
162
163
  { additionalProperties: false },
163
164
  );
164
165
 
165
166
  export const hashlineEditParamsSchema = Type.Object(
166
167
  {
168
+ path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
167
169
  edits: Type.Array(hashlineEditSchema, { description: "edits" }),
168
170
  },
169
171
  { additionalProperties: false },
@@ -182,6 +184,11 @@ export interface ExecuteHashlineSingleOptions {
182
184
  beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
183
185
  }
184
186
 
187
+ /**
188
+ * Normalize line payloads for apply: strip read/grep line prefixes. The tool schema
189
+ * supplies `string[]` (one element per line). `null` / `undefined` yield `[]`.
190
+ * A single multiline `string` is still split on `\n` for the same normalization path.
191
+ */
185
192
  export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
186
193
  if (edit == null) return [];
187
194
  if (typeof edit === "string") {
@@ -191,15 +198,6 @@ export function hashlineParseText(edit: string[] | string | null | undefined): s
191
198
  return stripNewLinePrefixes(edit);
192
199
  }
193
200
 
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
201
  function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
204
202
  return edits.map(resolveEditAnchor);
205
203
  }
@@ -224,6 +222,15 @@ function resolveHashlineEditsForDiff(edits: HashlineEditInput[]): HashlineEdit[]
224
222
  });
225
223
  }
226
224
 
225
+ export function formatFullAnchorRequirement(raw?: string): string {
226
+ const suffix = typeof raw === "string" ? raw.trim() : "";
227
+ const hashOnlyHint = /^[A-Za-z]{2}$/.test(suffix)
228
+ ? ` It looks like you supplied only the 2-letter suffix (${JSON.stringify(suffix)}). Copy the full anchor exactly as shown (for example, "160${suffix}").`
229
+ : "";
230
+ const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
231
+ return `the full anchor exactly as shown by read/grep (line number + 2-letter suffix, for example "160sr")${received}${hashOnlyHint}`;
232
+ }
233
+
227
234
  function tryParseTag(raw: string): Anchor | undefined {
228
235
  try {
229
236
  return parseTag(raw);
@@ -234,14 +241,24 @@ function tryParseTag(raw: string): Anchor | undefined {
234
241
 
235
242
  function requireParsedAnchor(raw: string, op: "append" | "prepend"): Anchor {
236
243
  const anchor = tryParseTag(raw);
237
- if (!anchor) throw new Error(`${op} requires a valid anchor.`);
244
+ if (!anchor) throw new Error(`${op} requires ${formatFullAnchorRequirement(raw)}.`);
238
245
  return anchor;
239
246
  }
240
247
 
241
248
  function requireParsedRange(range: { pos: string; end: string }): { pos: Anchor; end: Anchor } {
242
249
  const pos = tryParseTag(range.pos);
243
250
  const end = tryParseTag(range.end);
244
- if (!pos || !end) throw new Error("range requires valid pos and end anchors.");
251
+ if (!pos || !end) {
252
+ const invalid = [
253
+ !pos ? `pos=${JSON.stringify(range.pos)}` : null,
254
+ !end ? `end=${JSON.stringify(range.end)}` : null,
255
+ ]
256
+ .filter(Boolean)
257
+ .join(", ");
258
+ throw new Error(
259
+ `range requires valid pos and end anchors. Use ${formatFullAnchorRequirement()}. Invalid: ${invalid}.`,
260
+ );
261
+ }
245
262
  return { pos, end };
246
263
  }
247
264
 
@@ -296,8 +313,6 @@ interface ResolvedHashlineStreamOptions {
296
313
  maxChunkBytes: number;
297
314
  }
298
315
 
299
- type HashlineLineFormatter = (lineNumber: number, line: string) => string;
300
-
301
316
  interface HashlineChunkEmitter {
302
317
  pushLine: (line: string) => string[];
303
318
  flush: () => string | undefined;
@@ -313,7 +328,7 @@ function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedH
313
328
 
314
329
  function createHashlineChunkEmitter(
315
330
  options: ResolvedHashlineStreamOptions,
316
- formatLine: HashlineLineFormatter,
331
+ formatLine = formatHashLine,
317
332
  ): HashlineChunkEmitter {
318
333
  let lineNumber = options.startLine;
319
334
  let outLines: string[] = [];
@@ -357,10 +372,6 @@ function createHashlineChunkEmitter(
357
372
  return { pushLine, flush };
358
373
  }
359
374
 
360
- function formatHashlineStreamLine(lineNumber: number, line: string): string {
361
- return `${formatLineHash(lineNumber, line)}:${line}`;
362
- }
363
-
364
375
  function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
365
376
  return (
366
377
  typeof value === "object" &&
@@ -400,7 +411,7 @@ export async function* streamHashLinesFromUtf8(
400
411
  let pending = "";
401
412
  let sawAnyText = false;
402
413
  let endedWithNewline = false;
403
- const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine);
414
+ const emitter = createHashlineChunkEmitter(resolvedOptions);
404
415
 
405
416
  const consumeText = (text: string): string[] => {
406
417
  if (text.length === 0) return [];
@@ -453,7 +464,7 @@ export async function* streamHashLinesFromLines(
453
464
  options: HashlineStreamOptions = {},
454
465
  ): AsyncGenerator<string> {
455
466
  const resolvedOptions = resolveHashlineStreamOptions(options);
456
- const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine);
467
+ const emitter = createHashlineChunkEmitter(resolvedOptions);
457
468
  let sawAnyLine = false;
458
469
 
459
470
  const asyncIterator = (lines as AsyncIterable<string>)[Symbol.asyncIterator];
@@ -484,20 +495,18 @@ export async function* streamHashLinesFromLines(
484
495
  }
485
496
 
486
497
  /**
487
- * Parse a line reference string like `"5#abcd"` into structured form.
498
+ * Parse a line reference string like `"5th"` into structured form.
488
499
  *
489
- * @throws Error if the format is invalid (not `NUMBER#HEXHASH`)
500
+ * @throws Error if the format is invalid (not `NUMBERBIGRAM`)
490
501
  */
491
502
  export function parseTag(ref: string): { line: number; hash: string } {
492
- // This regex captures:
493
- // 1. optional leading ">+" and whitespace
503
+ // Captures:
504
+ // 1. optional leading ">+-" markers and whitespace
494
505
  // 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})/);
506
+ // 3. hash (one BPE bigram from HASHLINE_BIGRAMS) directly adjacent (no separator)
507
+ const match = ref.match(new RegExp(`^\\s*[>+-]*\\s*(\\d+)(${HASHLINE_BIGRAM_RE_SRC})`));
499
508
  if (!match) {
500
- throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`);
509
+ throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
501
510
  }
502
511
  const line = Number.parseInt(match[1], 10);
503
512
  if (line < 1) {
@@ -516,8 +525,8 @@ const MISMATCH_CONTEXT = 2;
516
525
  /**
517
526
  * Error thrown when one or more hashline references have stale hashes.
518
527
  *
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.
528
+ * Displays grep-style output with `>` separator on mismatched lines and `:` on
529
+ * surrounding context, showing the correct `LINE+ID` so the caller can fix all refs at once.
521
530
  */
522
531
  export class HashlineMismatchError extends Error {
523
532
  readonly remaps: ReadonlyMap<string, string>;
@@ -530,11 +539,50 @@ export class HashlineMismatchError extends Error {
530
539
  const remaps = new Map<string, string>();
531
540
  for (const m of mismatches) {
532
541
  const actual = computeLineHash(m.line, fileLines[m.line - 1]);
533
- remaps.set(`${m.line}#${m.expected}`, `${m.line}#${actual}`);
542
+ remaps.set(`${m.line}${m.expected}`, `${m.line}${actual}`);
534
543
  }
535
544
  this.remaps = remaps;
536
545
  }
537
546
 
547
+ /**
548
+ * User-visible variant of {@link formatMessage} — omits the bigram fingerprint
549
+ * and uses a `│` gutter so TUI rendering is clean. The model still receives
550
+ * the full `LINE+ID|content` form via {@link Error.message}.
551
+ */
552
+ get displayMessage(): string {
553
+ return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
554
+ }
555
+
556
+ static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
557
+ const mismatchSet = new Set<number>();
558
+ for (const m of mismatches) mismatchSet.add(m.line);
559
+
560
+ const displayLines = new Set<number>();
561
+ for (const m of mismatches) {
562
+ const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
563
+ const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT);
564
+ for (let i = lo; i <= hi; i++) displayLines.add(i);
565
+ }
566
+
567
+ const sorted = [...displayLines].sort((a, b) => a - b);
568
+ const out: string[] = [
569
+ `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
570
+ "Realign your edit to the file state shown below. Copy the full anchors exactly as shown (for example `160sr`, not just `sr`).",
571
+ "",
572
+ ];
573
+
574
+ const lineNumberWidth = sorted.reduce((width, lineNum) => Math.max(width, String(lineNum).length), 0);
575
+ let prevLine = -1;
576
+ for (const lineNum of sorted) {
577
+ if (prevLine !== -1 && lineNum > prevLine + 1) out.push("...");
578
+ prevLine = lineNum;
579
+ const text = fileLines[lineNum - 1];
580
+ const marker = mismatchSet.has(lineNum) ? "*" : " ";
581
+ out.push(formatCodeFrameLine(marker, lineNum, text ?? "", lineNumberWidth));
582
+ }
583
+ return out.join("\n");
584
+ }
585
+
538
586
  static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
539
587
  const mismatchSet = new Map<number, HashMismatch>();
540
588
  for (const m of mismatches) {
@@ -555,7 +603,8 @@ export class HashlineMismatchError extends Error {
555
603
  const lines: string[] = [];
556
604
 
557
605
  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.`,
606
+ `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
607
+ "Use the updated anchors shown below (`>` marks changed lines, `:` marks context) and retry the edit.",
559
608
  );
560
609
  lines.push("");
561
610
 
@@ -563,18 +612,18 @@ export class HashlineMismatchError extends Error {
563
612
  for (const lineNum of sorted) {
564
613
  // Gap separator between non-contiguous regions
565
614
  if (prevLine !== -1 && lineNum > prevLine + 1) {
566
- lines.push(" ...");
615
+ lines.push("...");
567
616
  }
568
617
  prevLine = lineNum;
569
618
 
570
619
  const text = fileLines[lineNum - 1];
571
620
  const hash = computeLineHash(lineNum, text);
572
- const prefix = `${lineNum}#${hash}`;
621
+ const prefix = `${lineNum}${hash}`;
573
622
 
574
623
  if (mismatchSet.has(lineNum)) {
575
- lines.push(`>>> ${prefix}:${text}`);
624
+ lines.push(`${prefix}>${text}`);
576
625
  } else {
577
- lines.push(` ${prefix}:${text}`);
626
+ lines.push(`${prefix}:${text}`);
578
627
  }
579
628
  }
580
629
  return lines.join("\n");
@@ -599,6 +648,39 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
599
648
  }
600
649
  }
601
650
 
651
+ /**
652
+ * Default search window for {@link tryRebaseAnchor} (lines on each side of the requested anchor).
653
+ */
654
+ export const ANCHOR_REBASE_WINDOW = 2;
655
+
656
+ /**
657
+ * Look for the requested hash within ±`window` lines of `anchor.line`.
658
+ *
659
+ * Returns the new line number when exactly one nearby line matches the hash;
660
+ * otherwise `null` (genuine mismatch or ambiguous). The caller is expected to
661
+ * mutate `anchor.line` in place and surface a warning so the model knows the
662
+ * edit was retargeted.
663
+ *
664
+ * The exact-position match (anchor.line itself) is intentionally skipped: the
665
+ * caller has already determined the requested line's hash does not match.
666
+ */
667
+ export function tryRebaseAnchor(
668
+ anchor: { line: number; hash: string },
669
+ fileLines: string[],
670
+ window: number = ANCHOR_REBASE_WINDOW,
671
+ ): number | null {
672
+ const lo = Math.max(1, anchor.line - window);
673
+ const hi = Math.min(fileLines.length, anchor.line + window);
674
+ let found: number | null = null;
675
+ for (let line = lo; line <= hi; line++) {
676
+ if (line === anchor.line) continue;
677
+ if (computeLineHash(line, fileLines[line - 1]) !== anchor.hash) continue;
678
+ if (found !== null) return null; // ambiguous: more than one match in window
679
+ found = line;
680
+ }
681
+ return found;
682
+ }
683
+
602
684
  function isEscapedTabAutocorrectEnabled(): boolean {
603
685
  switch (Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS) {
604
686
  case "0":
@@ -675,7 +757,7 @@ function collectBoundaryDuplicationWarning(edit: HashlineEdit, originalFileLines
675
757
  const trimmedNext = nextSurvivingLine.trim();
676
758
  const trimmedLast = lastInsertedLine.trim();
677
759
  if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
678
- const tag = formatLineHash(endLine + 1, nextSurvivingLine);
760
+ const tag = formatHashLine(endLine + 1, nextSurvivingLine);
679
761
  warnings.push(
680
762
  `Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
681
763
  `If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
@@ -754,7 +836,7 @@ function applyHashlineEditToLines(
754
836
  if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
755
837
  noopEdits.push({
756
838
  editIndex,
757
- loc: `${edit.pos.line}#${edit.pos.hash}`,
839
+ loc: `${edit.pos.line}${edit.pos.hash}`,
758
840
  current: origLines.join("\n"),
759
841
  });
760
842
  break;
@@ -765,6 +847,15 @@ function applyHashlineEditToLines(
765
847
  }
766
848
  case "replace_range": {
767
849
  const count = edit.end.line - edit.pos.line + 1;
850
+ const origRange = originalFileLines.slice(edit.pos.line - 1, edit.pos.line - 1 + count);
851
+ if (count === edit.lines.length && origRange.every((line, i) => line === edit.lines[i])) {
852
+ noopEdits.push({
853
+ editIndex,
854
+ loc: `${edit.pos.line}${edit.pos.hash}-${edit.end.line}${edit.end.hash}`,
855
+ current: origRange.join("\n"),
856
+ });
857
+ break;
858
+ }
768
859
  fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
769
860
  trackFirstChanged(edit.pos.line);
770
861
  break;
@@ -774,7 +865,7 @@ function applyHashlineEditToLines(
774
865
  if (inserted.length === 0) {
775
866
  noopEdits.push({
776
867
  editIndex,
777
- loc: `${edit.pos.line}#${edit.pos.hash}`,
868
+ loc: `${edit.pos.line}${edit.pos.hash}`,
778
869
  current: originalFileLines[edit.pos.line - 1],
779
870
  });
780
871
  break;
@@ -788,7 +879,7 @@ function applyHashlineEditToLines(
788
879
  if (inserted.length === 0) {
789
880
  noopEdits.push({
790
881
  editIndex,
791
- loc: `${edit.pos.line}#${edit.pos.hash}`,
882
+ loc: `${edit.pos.line}${edit.pos.hash}`,
792
883
  current: originalFileLines[edit.pos.line - 1],
793
884
  });
794
885
  break;
@@ -849,7 +940,7 @@ function buildHashlineEditResult(params: {
849
940
  };
850
941
  }
851
942
 
852
- function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] {
943
+ function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
853
944
  const mismatches: HashMismatch[] = [];
854
945
  for (const edit of edits) {
855
946
  switch (edit.op) {
@@ -884,6 +975,15 @@ function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): H
884
975
  if (actualHash === ref.hash) {
885
976
  return;
886
977
  }
978
+ const rebased = tryRebaseAnchor(ref, fileLines);
979
+ if (rebased !== null) {
980
+ const original = `${ref.line}${ref.hash}`;
981
+ ref.line = rebased;
982
+ warnings.push(
983
+ `Auto-rebased anchor ${original} → ${rebased}${ref.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
984
+ );
985
+ return;
986
+ }
887
987
  mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
888
988
  }
889
989
  }
@@ -922,7 +1022,7 @@ export function applyHashlineEdits(
922
1022
  const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = [];
923
1023
  const warnings: string[] = [];
924
1024
 
925
- const mismatches = validateHashlineEditRefs(edits, fileLines);
1025
+ const mismatches = validateHashlineEditRefs(edits, fileLines, warnings);
926
1026
  if (mismatches.length > 0) {
927
1027
  throw new HashlineMismatchError(mismatches, fileLines);
928
1028
  }
@@ -1022,14 +1122,12 @@ function syncNewLineCounters(counters: CompactPreviewCounters, lineNumber: numbe
1022
1122
  counters.newLine = lineNumber;
1023
1123
  }
1024
1124
 
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}`;
1125
+ function formatCompactHashlineLine(kind: " " | "+", lineNumber: number, content: string): string {
1126
+ return `${kind}${lineNumber}${computeLineHash(lineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
1028
1127
  }
1029
1128
 
1030
- function formatCompactRemovedLine(lineNumber: number, width: number, content: string): string {
1031
- const padded = String(lineNumber).padStart(width, " ");
1032
- return `-${padded}${HASHLINE_PREVIEW_PLACEHOLDER}|${content}`;
1129
+ function formatCompactRemovedLine(lineNumber: number, content: string): string {
1130
+ return `-${lineNumber}${HASHLINE_PREVIEW_PLACEHOLDER}${HASHLINE_CONTENT_SEPARATOR}${content}`;
1033
1131
  }
1034
1132
 
1035
1133
  function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters): { kind: DiffRunKind; text: string } {
@@ -1050,13 +1148,13 @@ function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters
1050
1148
  syncNewLineCounters(counters, parsed.lineNumber);
1051
1149
  const newLine = counters.newLine;
1052
1150
  if (newLine === undefined) return { kind: "+", text: parsed.raw };
1053
- const text = formatCompactHashlineLine("+", newLine, parsed.lineWidth, parsed.content);
1151
+ const text = formatCompactHashlineLine("+", newLine, parsed.content);
1054
1152
  counters.newLine = newLine + 1;
1055
1153
  return { kind: "+", text };
1056
1154
  }
1057
1155
  case "-": {
1058
1156
  syncOldLineCounters(counters, parsed.lineNumber);
1059
- const text = formatCompactRemovedLine(parsed.lineNumber, parsed.lineWidth, parsed.content);
1157
+ const text = formatCompactRemovedLine(parsed.lineNumber, parsed.content);
1060
1158
  counters.oldLine = parsed.lineNumber + 1;
1061
1159
  return { kind: "-", text };
1062
1160
  }
@@ -1064,7 +1162,7 @@ function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters
1064
1162
  syncOldLineCounters(counters, parsed.lineNumber);
1065
1163
  const newLine = counters.newLine;
1066
1164
  if (newLine === undefined) return { kind: " ", text: parsed.raw };
1067
- const text = formatCompactHashlineLine(" ", newLine, parsed.lineWidth, parsed.content);
1165
+ const text = formatCompactHashlineLine(" ", newLine, parsed.content);
1068
1166
  counters.oldLine = parsed.lineNumber + 1;
1069
1167
  counters.newLine = newLine + 1;
1070
1168
  return { kind: " ", text };
@@ -1170,7 +1268,7 @@ export function buildCompactHashlineDiffPreview(
1170
1268
  }
1171
1269
 
1172
1270
  export async function computeHashlineDiff(
1173
- input: { path: string; edits: HashlineEditInput[]; move?: string },
1271
+ input: { path: string; edits: HashlineEditInput[] },
1174
1272
  cwd: string,
1175
1273
  ): Promise<
1176
1274
  | {
@@ -1181,28 +1279,19 @@ export async function computeHashlineDiff(
1181
1279
  error: string;
1182
1280
  }
1183
1281
  > {
1184
- const { path, edits, move } = input;
1282
+ const { path, edits } = input;
1185
1283
 
1186
1284
  try {
1187
1285
  const absolutePath = resolveToCwd(path, cwd);
1188
- const movePath = move ? resolveToCwd(move, cwd) : undefined;
1189
- const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
1190
1286
  const resolvedEdits = resolveHashlineEditsForDiff(edits);
1191
1287
  const file = Bun.file(absolutePath);
1192
1288
 
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
1289
  const rawContent = await readHashlineFileText(file, path);
1201
1290
 
1202
1291
  const { text: content } = stripBom(rawContent);
1203
1292
  const normalizedContent = normalizeToLF(content);
1204
1293
  const result = applyHashlineEdits(normalizedContent, resolvedEdits);
1205
- if (normalizedContent === result.lines && !move) {
1294
+ if (normalizedContent === result.lines) {
1206
1295
  return { error: `No changes would be made to ${path}. The edits produce identical content.` };
1207
1296
  }
1208
1297
 
@@ -1229,63 +1318,18 @@ export async function executeHashlineSingle(
1229
1318
  ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1230
1319
  const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
1231
1320
 
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
1321
  const contentEdits = edits.filter(e => e.loc != null);
1237
1322
 
1238
- enforcePlanModeWrite(session, path, { op: deleteFile ? "delete" : "update", move });
1323
+ enforcePlanModeWrite(session, path, { op: "update" });
1239
1324
 
1240
1325
  if (path.endsWith(".ipynb") && contentEdits.length > 0) {
1241
1326
  throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1242
1327
  }
1243
1328
 
1244
1329
  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
1330
 
1250
1331
  const sourceFile = Bun.file(absolutePath);
1251
1332
  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
1333
 
1290
1334
  if (!sourceExists) {
1291
1335
  const lines: string[] = [];
@@ -1329,7 +1373,7 @@ export async function executeHashlineSingle(
1329
1373
  warnings: anchorResult.warnings,
1330
1374
  noopEdits: anchorResult.noopEdits,
1331
1375
  };
1332
- if (originalNormalized === result.text && !move) {
1376
+ if (originalNormalized === result.text) {
1333
1377
  let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
1334
1378
  if (result.noopEdits && result.noopEdits.length > 0) {
1335
1379
  const details = result.noopEdits
@@ -1345,28 +1389,32 @@ export async function executeHashlineSingle(
1345
1389
  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
1390
  }
1347
1391
  }
1392
+ if (result.noopEdits.some(e => e.loc.includes("-"))) {
1393
+ diagnostic +=
1394
+ "\nHint: a `range` loc replaces the entire span inclusive of both endpoints. " +
1395
+ "If your replacement repeats the existing content, narrow the range or change the replacement.";
1396
+ }
1348
1397
  }
1349
1398
  throw new Error(diagnostic);
1350
1399
  }
1351
1400
 
1352
- const writePath = resolvedMove ?? absolutePath;
1353
1401
  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,
1402
+ const diagnostics = await writethrough(
1403
+ absolutePath,
1404
+ finalContent,
1405
+ signal,
1406
+ Bun.file(absolutePath),
1407
+ batchRequest,
1408
+ dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
1356
1409
  );
1357
- if (resolvedMove && resolvedMove !== absolutePath) {
1358
- await sourceFile.unlink();
1359
- invalidateFsScanAfterRename(absolutePath, resolvedMove);
1360
- } else {
1361
- invalidateFsScanAfterWrite(absolutePath);
1362
- }
1410
+ invalidateFsScanAfterWrite(absolutePath);
1363
1411
 
1364
1412
  const diffResult = generateDiffString(originalNormalized, result.text);
1365
1413
  const meta = outputMeta()
1366
1414
  .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
1367
1415
  .get();
1368
1416
 
1369
- const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`;
1417
+ const resultText = `Updated ${path}`;
1370
1418
  const preview = buildCompactHashlineDiffPreview(diffResult.diff);
1371
1419
  const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`;
1372
1420
  const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
@@ -1384,7 +1432,6 @@ export async function executeHashlineSingle(
1384
1432
  firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
1385
1433
  diagnostics,
1386
1434
  op: "update",
1387
- move,
1388
1435
  meta,
1389
1436
  },
1390
1437
  };