@oh-my-pi/pi-coding-agent 15.3.2 → 15.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 (191) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/types/cli/file-processor.d.ts +1 -1
  3. package/dist/types/config/settings-schema.d.ts +45 -3
  4. package/dist/types/config/settings.d.ts +1 -1
  5. package/dist/types/debug/raw-sse.d.ts +2 -0
  6. package/dist/types/edit/file-read-cache.d.ts +15 -4
  7. package/dist/types/edit/index.d.ts +3 -8
  8. package/dist/types/edit/renderer.d.ts +1 -2
  9. package/dist/types/eval/__tests__/shared-executors.test.d.ts +1 -0
  10. package/dist/types/eval/js/shared/local-module-loader.d.ts +16 -0
  11. package/dist/types/eval/js/shared/rewrite-imports.d.ts +4 -0
  12. package/dist/types/eval/js/shared/runtime.d.ts +14 -8
  13. package/dist/types/eval/py/executor.d.ts +1 -2
  14. package/dist/types/eval/py/kernel.d.ts +6 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -5
  16. package/dist/types/eval/session-id.d.ts +3 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +1 -3
  18. package/dist/types/hashline/anchors.d.ts +15 -9
  19. package/dist/types/hashline/constants.d.ts +0 -2
  20. package/dist/types/hashline/diff.d.ts +1 -2
  21. package/dist/types/hashline/executor.d.ts +52 -0
  22. package/dist/types/hashline/hash.d.ts +44 -93
  23. package/dist/types/hashline/index.d.ts +2 -1
  24. package/dist/types/hashline/input.d.ts +2 -9
  25. package/dist/types/hashline/recovery.d.ts +3 -9
  26. package/dist/types/hashline/tokenizer.d.ts +91 -0
  27. package/dist/types/hashline/types.d.ts +5 -7
  28. package/dist/types/modes/components/extensions/types.d.ts +0 -4
  29. package/dist/types/modes/types.d.ts +1 -0
  30. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +2 -0
  32. package/dist/types/session/agent-session.d.ts +11 -15
  33. package/dist/types/session/agent-storage.d.ts +11 -10
  34. package/dist/types/slash-commands/acp-builtins.d.ts +3 -3
  35. package/dist/types/slash-commands/types.d.ts +0 -5
  36. package/dist/types/task/executor.d.ts +2 -0
  37. package/dist/types/tool-discovery/tool-index.d.ts +0 -50
  38. package/dist/types/tools/index.d.ts +2 -8
  39. package/dist/types/tools/match-line-format.d.ts +4 -4
  40. package/dist/types/tools/output-schema-validator.d.ts +64 -0
  41. package/dist/types/tools/review.d.ts +13 -0
  42. package/dist/types/tools/search-tool-bm25.d.ts +1 -1
  43. package/dist/types/tools/search.d.ts +4 -3
  44. package/dist/types/utils/edit-mode.d.ts +1 -1
  45. package/dist/types/web/kagi.d.ts +4 -2
  46. package/dist/types/web/parallel.d.ts +4 -3
  47. package/dist/types/web/scrapers/types.d.ts +2 -1
  48. package/dist/types/web/search/index.d.ts +12 -4
  49. package/dist/types/web/search/provider.d.ts +2 -1
  50. package/dist/types/web/search/providers/anthropic.d.ts +9 -4
  51. package/dist/types/web/search/providers/base.d.ts +34 -2
  52. package/dist/types/web/search/providers/brave.d.ts +8 -1
  53. package/dist/types/web/search/providers/codex.d.ts +13 -9
  54. package/dist/types/web/search/providers/exa.d.ts +10 -1
  55. package/dist/types/web/search/providers/gemini.d.ts +20 -23
  56. package/dist/types/web/search/providers/jina.d.ts +2 -1
  57. package/dist/types/web/search/providers/kagi.d.ts +4 -1
  58. package/dist/types/web/search/providers/kimi.d.ts +10 -1
  59. package/dist/types/web/search/providers/parallel.d.ts +3 -2
  60. package/dist/types/web/search/providers/perplexity.d.ts +5 -2
  61. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  62. package/dist/types/web/search/providers/synthetic.d.ts +5 -8
  63. package/dist/types/web/search/providers/tavily.d.ts +11 -4
  64. package/dist/types/web/search/providers/utils.d.ts +8 -6
  65. package/dist/types/web/search/providers/zai.d.ts +12 -3
  66. package/package.json +7 -7
  67. package/src/cli/file-processor.ts +12 -2
  68. package/src/cli.ts +0 -8
  69. package/src/commands/commit.ts +8 -8
  70. package/src/config/prompt-templates.ts +6 -6
  71. package/src/config/settings-schema.ts +47 -3
  72. package/src/config/settings.ts +5 -5
  73. package/src/debug/raw-sse.ts +68 -3
  74. package/src/edit/file-read-cache.ts +68 -25
  75. package/src/edit/index.ts +6 -37
  76. package/src/edit/renderer.ts +9 -47
  77. package/src/edit/streaming.ts +43 -56
  78. package/src/eval/__tests__/shared-executors.test.ts +520 -0
  79. package/src/eval/js/context-manager.ts +64 -53
  80. package/src/eval/js/shared/local-module-loader.ts +265 -0
  81. package/src/eval/js/shared/prelude.txt +4 -0
  82. package/src/eval/js/shared/rewrite-imports.ts +85 -0
  83. package/src/eval/js/shared/runtime.ts +129 -86
  84. package/src/eval/js/worker-core.ts +23 -38
  85. package/src/eval/py/executor.ts +155 -84
  86. package/src/eval/py/kernel.ts +10 -1
  87. package/src/eval/py/prelude.py +22 -24
  88. package/src/eval/py/runner.py +203 -85
  89. package/src/eval/py/tool-bridge.ts +17 -10
  90. package/src/eval/session-id.ts +8 -0
  91. package/src/exec/bash-executor.ts +27 -16
  92. package/src/extensibility/extensions/runner.ts +0 -1
  93. package/src/extensibility/extensions/types.ts +1 -3
  94. package/src/hashline/anchors.ts +56 -65
  95. package/src/hashline/apply.ts +29 -31
  96. package/src/hashline/constants.ts +0 -3
  97. package/src/hashline/diff-preview.ts +4 -5
  98. package/src/hashline/diff.ts +30 -4
  99. package/src/hashline/execute.ts +91 -26
  100. package/src/hashline/executor.ts +239 -0
  101. package/src/hashline/grammar.lark +12 -10
  102. package/src/hashline/hash.ts +69 -114
  103. package/src/hashline/index.ts +2 -1
  104. package/src/hashline/input.ts +48 -41
  105. package/src/hashline/prefixes.ts +21 -11
  106. package/src/hashline/recovery.ts +63 -71
  107. package/src/hashline/stream.ts +2 -2
  108. package/src/hashline/tokenizer.ts +467 -0
  109. package/src/hashline/types.ts +6 -8
  110. package/src/internal-urls/docs-index.generated.ts +7 -7
  111. package/src/modes/components/extensions/types.ts +0 -5
  112. package/src/modes/components/session-observer-overlay.ts +11 -2
  113. package/src/modes/components/tree-selector.ts +10 -2
  114. package/src/modes/controllers/command-controller.ts +1 -3
  115. package/src/modes/controllers/extension-ui-controller.ts +10 -11
  116. package/src/modes/controllers/selector-controller.ts +5 -5
  117. package/src/modes/types.ts +4 -1
  118. package/src/modes/utils/ui-helpers.ts +4 -0
  119. package/src/prompts/agents/explore.md +1 -1
  120. package/src/prompts/tools/ast-edit.md +1 -1
  121. package/src/prompts/tools/ast-grep.md +1 -1
  122. package/src/prompts/tools/eval.md +1 -1
  123. package/src/prompts/tools/hashline.md +73 -94
  124. package/src/prompts/tools/read.md +4 -4
  125. package/src/prompts/tools/search.md +3 -3
  126. package/src/sdk.ts +17 -23
  127. package/src/session/agent-session.ts +59 -66
  128. package/src/session/agent-storage.ts +13 -14
  129. package/src/slash-commands/acp-builtins.ts +3 -3
  130. package/src/slash-commands/types.ts +0 -6
  131. package/src/task/executor.ts +26 -57
  132. package/src/task/index.ts +8 -4
  133. package/src/tool-discovery/tool-index.ts +0 -134
  134. package/src/tools/ast-edit.ts +36 -13
  135. package/src/tools/ast-grep.ts +45 -4
  136. package/src/tools/browser/tab-worker.ts +3 -2
  137. package/src/tools/eval.ts +2 -1
  138. package/src/tools/fetch.ts +23 -14
  139. package/src/tools/index.ts +2 -8
  140. package/src/tools/irc.ts +59 -5
  141. package/src/tools/match-line-format.ts +5 -7
  142. package/src/tools/output-schema-validator.ts +132 -0
  143. package/src/tools/read.ts +142 -31
  144. package/src/tools/review.ts +23 -0
  145. package/src/tools/search-tool-bm25.ts +3 -30
  146. package/src/tools/search.ts +48 -16
  147. package/src/tools/write.ts +3 -3
  148. package/src/tools/yield.ts +32 -41
  149. package/src/utils/edit-mode.ts +1 -2
  150. package/src/utils/file-mentions.ts +2 -2
  151. package/src/web/kagi.ts +15 -6
  152. package/src/web/parallel.ts +9 -6
  153. package/src/web/scrapers/types.ts +7 -1
  154. package/src/web/scrapers/youtube.ts +13 -7
  155. package/src/web/search/index.ts +37 -11
  156. package/src/web/search/provider.ts +5 -3
  157. package/src/web/search/providers/anthropic.ts +30 -21
  158. package/src/web/search/providers/base.ts +35 -2
  159. package/src/web/search/providers/brave.ts +4 -4
  160. package/src/web/search/providers/codex.ts +118 -89
  161. package/src/web/search/providers/exa.ts +3 -2
  162. package/src/web/search/providers/gemini.ts +58 -155
  163. package/src/web/search/providers/jina.ts +4 -4
  164. package/src/web/search/providers/kagi.ts +17 -11
  165. package/src/web/search/providers/kimi.ts +29 -13
  166. package/src/web/search/providers/parallel.ts +171 -23
  167. package/src/web/search/providers/perplexity.ts +38 -37
  168. package/src/web/search/providers/searxng.ts +3 -1
  169. package/src/web/search/providers/synthetic.ts +16 -19
  170. package/src/web/search/providers/tavily.ts +23 -18
  171. package/src/web/search/providers/utils.ts +11 -17
  172. package/src/web/search/providers/zai.ts +16 -8
  173. package/dist/types/hashline/parser.d.ts +0 -7
  174. package/dist/types/mcp/discoverable-tool-metadata.d.ts +0 -7
  175. package/dist/types/tools/vim.d.ts +0 -58
  176. package/dist/types/vim/buffer.d.ts +0 -41
  177. package/dist/types/vim/commands.d.ts +0 -6
  178. package/dist/types/vim/engine.d.ts +0 -47
  179. package/dist/types/vim/parser.d.ts +0 -3
  180. package/dist/types/vim/render.d.ts +0 -25
  181. package/dist/types/vim/types.d.ts +0 -182
  182. package/src/hashline/parser.ts +0 -246
  183. package/src/mcp/discoverable-tool-metadata.ts +0 -24
  184. package/src/prompts/tools/vim.md +0 -98
  185. package/src/tools/vim.ts +0 -949
  186. package/src/vim/buffer.ts +0 -309
  187. package/src/vim/commands.ts +0 -382
  188. package/src/vim/engine.ts +0 -2409
  189. package/src/vim/parser.ts +0 -134
  190. package/src/vim/render.ts +0 -252
  191. package/src/vim/types.ts +0 -197
@@ -1,113 +1,104 @@
1
- import { formatCodeFrameLine } from "../tools/render-utils";
2
1
  import { MISMATCH_CONTEXT } from "./constants";
3
- import { computeLineHash, describeAnchorExamples, HL_ANCHOR_RE_RAW, HL_BODY_SEP } from "./hash";
4
- import type { HashMismatch } from "./types";
2
+ import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./hash";
5
3
 
6
- const HL_HASH_HINT_RE = /^[a-z]{2}$/i;
7
- const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160");
8
- const PARSE_TAG_RE = new RegExp(`^${HL_ANCHOR_RE_RAW}`);
4
+ const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/;
9
5
 
10
6
  export function formatFullAnchorRequirement(raw?: string): string {
11
- const suffix = typeof raw === "string" ? raw.trim() : "";
12
- const hashOnlyHint = HL_HASH_HINT_RE.test(suffix)
13
- ? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` +
14
- `Copy the full anchor exactly as shown (for example, "160${suffix}").`
15
- : "";
16
7
  const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
17
8
  return (
18
- `the full anchor exactly as shown by read/search output ` +
19
- `(line number + hash, for example ${HL_ANCHOR_EXAMPLES})${received}${hashOnlyHint}`
9
+ `a bare line number from read/search output plus the section header file hash ` +
10
+ `(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}1a2b and line "160")${received}`
20
11
  );
21
12
  }
22
13
 
23
- export function parseTag(ref: string): { line: number; hash: string } {
24
- const match = ref.match(PARSE_TAG_RE);
14
+ export function parseTag(ref: string): { line: number } {
15
+ const match = ref.match(LINE_REF_RE);
25
16
  if (!match) {
26
17
  throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
27
18
  }
28
19
  const line = Number.parseInt(match[1], 10);
29
20
  if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
30
- return { line, hash: match[2] };
21
+ return { line };
31
22
  }
32
23
 
33
- function getMismatchDisplayLines(mismatches: HashMismatch[], fileLines: string[]): number[] {
24
+ export interface HashlineMismatchDetails {
25
+ path?: string;
26
+ expectedFileHash: string;
27
+ actualFileHash: string;
28
+ fileLines: string[];
29
+ anchorLines?: readonly number[];
30
+ }
31
+
32
+ function getMismatchDisplayLines(anchorLines: readonly number[], fileLines: string[]): number[] {
34
33
  const displayLines = new Set<number>();
35
- for (const mismatch of mismatches) {
36
- const lo = Math.max(1, mismatch.line - MISMATCH_CONTEXT);
37
- const hi = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT);
34
+ for (const line of anchorLines) {
35
+ if (line < 1 || line > fileLines.length) continue;
36
+ const lo = Math.max(1, line - MISMATCH_CONTEXT);
37
+ const hi = Math.min(fileLines.length, line + MISMATCH_CONTEXT);
38
38
  for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum);
39
39
  }
40
40
  return [...displayLines].sort((a, b) => a - b);
41
41
  }
42
42
 
43
43
  export class HashlineMismatchError extends Error {
44
- readonly remaps: ReadonlyMap<string, string>;
44
+ readonly path: string | undefined;
45
+ readonly expectedFileHash: string;
46
+ readonly actualFileHash: string;
47
+ readonly fileLines: string[];
48
+ readonly anchorLines: readonly number[];
45
49
 
46
- constructor(
47
- public readonly mismatches: HashMismatch[],
48
- public readonly fileLines: string[],
49
- ) {
50
- super(HashlineMismatchError.formatMessage(mismatches, fileLines));
50
+ constructor(details: HashlineMismatchDetails) {
51
+ super(HashlineMismatchError.formatMessage(details));
51
52
  this.name = "HashlineMismatchError";
52
-
53
- const remaps = new Map<string, string>();
54
- for (const mismatch of mismatches) {
55
- const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "");
56
- remaps.set(`${mismatch.line}${mismatch.expected}`, `${mismatch.line}${actual}`);
57
- }
58
- this.remaps = remaps;
53
+ this.path = details.path;
54
+ this.expectedFileHash = details.expectedFileHash;
55
+ this.actualFileHash = details.actualFileHash;
56
+ this.fileLines = details.fileLines;
57
+ this.anchorLines = details.anchorLines ?? [];
59
58
  }
60
59
 
61
60
  get displayMessage(): string {
62
- return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
61
+ return HashlineMismatchError.formatDisplayMessage({
62
+ path: this.path,
63
+ expectedFileHash: this.expectedFileHash,
64
+ actualFileHash: this.actualFileHash,
65
+ fileLines: this.fileLines,
66
+ anchorLines: this.anchorLines,
67
+ });
63
68
  }
64
69
 
65
- private static rejectionHeader(mismatches: HashMismatch[]): string[] {
66
- const noun = mismatches.length > 1 ? "anchors do" : "anchor does";
70
+ static rejectionHeader(details: HashlineMismatchDetails): string[] {
71
+ const pathText = details.path ? ` for ${details.path}` : "";
67
72
  return [
68
- `Edit rejected: ${mismatches.length} ${noun} not match the current file (marked *).`,
69
- "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
73
+ `Edit rejected${pathText}: file changed between read and edit.`,
74
+ `Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}; re-read and try again.`,
70
75
  ];
71
76
  }
72
77
 
73
- static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
74
- const mismatchSet = new Set<number>(mismatches.map(m => m.line));
75
- const displayLines = getMismatchDisplayLines(mismatches, fileLines);
76
- const width = displayLines.reduce((cur, n) => Math.max(cur, String(n).length), 0);
77
-
78
- const out = [...HashlineMismatchError.rejectionHeader(mismatches), ""];
79
- let previous = -1;
80
- for (const lineNum of displayLines) {
81
- if (previous !== -1 && lineNum > previous + 1) out.push("...");
82
- previous = lineNum;
83
- const marker = mismatchSet.has(lineNum) ? "*" : " ";
84
- out.push(formatCodeFrameLine(marker, lineNum, fileLines[lineNum - 1] ?? "", width));
85
- }
86
- return out.join("\n");
78
+ static formatDisplayMessage(details: HashlineMismatchDetails): string {
79
+ return HashlineMismatchError.formatMessage(details);
87
80
  }
88
81
 
89
- static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
90
- const mismatchSet = new Set<number>(mismatches.map(m => m.line));
91
- const lines = HashlineMismatchError.rejectionHeader(mismatches);
82
+ static formatMessage(details: HashlineMismatchDetails): string {
83
+ const anchorSet = new Set(details.anchorLines ?? []);
84
+ const lines = HashlineMismatchError.rejectionHeader(details);
85
+ const displayLines = getMismatchDisplayLines(details.anchorLines ?? [], details.fileLines);
86
+ if (displayLines.length === 0) return lines.join("\n");
87
+ lines.push("");
92
88
  let previous = -1;
93
- for (const lineNum of getMismatchDisplayLines(mismatches, fileLines)) {
89
+ for (const lineNum of displayLines) {
94
90
  if (previous !== -1 && lineNum > previous + 1) lines.push("...");
95
91
  previous = lineNum;
96
- const text = fileLines[lineNum - 1] ?? "";
97
- const hash = computeLineHash(lineNum, text);
98
- const marker = mismatchSet.has(lineNum) ? "*" : " ";
99
- lines.push(`${marker}${lineNum}${hash}${HL_BODY_SEP}${text}`);
92
+ const text = details.fileLines[lineNum - 1] ?? "";
93
+ const marker = anchorSet.has(lineNum) ? "*" : " ";
94
+ lines.push(`${marker}${formatNumberedLine(lineNum, text)}`);
100
95
  }
101
96
  return lines.join("\n");
102
97
  }
103
98
  }
104
99
 
105
- export function validateLineRef(ref: { line: number; hash: string }, fileLines: string[]): void {
100
+ export function validateLineRef(ref: { line: number }, fileLines: string[]): void {
106
101
  if (ref.line < 1 || ref.line > fileLines.length) {
107
102
  throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
108
103
  }
109
- const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1] ?? "");
110
- if (actualHash !== ref.hash) {
111
- throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
112
- }
113
104
  }
@@ -1,8 +1,5 @@
1
- import { HashlineMismatchError } from "./anchors";
2
- import { RANGE_INTERIOR_HASH } from "./constants";
3
- import { computeLineHash } from "./hash";
4
- import { cloneCursor } from "./parser";
5
- import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit, HashMismatch } from "./types";
1
+ import { cloneCursor } from "./tokenizer";
2
+ import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit } from "./types";
6
3
 
7
4
  export interface HashlineApplyResult {
8
5
  lines: string;
@@ -43,26 +40,17 @@ function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
43
40
  }
44
41
 
45
42
  /**
46
- * Verify every anchor's hash. Any mismatch is reported as a `HashMismatch`;
47
- * there is no auto-rebase. Callers are expected to surface mismatches as
48
- * `HashlineMismatchError` so the model re-reads and re-anchors.
43
+ * Verify every anchored edit points at an existing line. File-version binding is
44
+ * checked once per section via the header hash before this function runs.
49
45
  */
50
- function validateHashlineAnchors(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] {
51
- const mismatches: HashMismatch[] = [];
46
+ function validateHashlineLineBounds(edits: HashlineEdit[], fileLines: string[]): void {
52
47
  for (const edit of edits) {
53
48
  for (const anchor of getHashlineEditAnchors(edit)) {
54
49
  if (anchor.line < 1 || anchor.line > fileLines.length) {
55
50
  throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
56
51
  }
57
- if (anchor.hash === RANGE_INTERIOR_HASH) continue;
58
-
59
- const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1] ?? "");
60
- if (actualHash === anchor.hash) continue;
61
-
62
- mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
63
52
  }
64
53
  }
65
- return mismatches;
66
54
  }
67
55
 
68
56
  function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void {
@@ -287,15 +275,10 @@ function contiguousRange(start: number, count: number): number[] {
287
275
  return Array.from({ length: count }, (_, offset) => start + offset);
288
276
  }
289
277
 
290
- function deleteEditForAutoAbsorbedLine(
291
- line: number,
292
- sourceLineNum: number,
293
- index: number,
294
- fileLines: string[],
295
- ): HashlineEdit {
278
+ function deleteEditForAutoAbsorbedLine(line: number, sourceLineNum: number, index: number): HashlineEdit {
296
279
  return {
297
280
  kind: "delete",
298
- anchor: { line, hash: computeLineHash(line, fileLines[line - 1] ?? "") },
281
+ anchor: { line },
299
282
  lineNum: sourceLineNum,
300
283
  index,
301
284
  };
@@ -314,7 +297,7 @@ function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean {
314
297
  if (a.kind === "bof" || a.kind === "eof") return true;
315
298
  const aAnchor = (a as { anchor: Anchor }).anchor;
316
299
  const bAnchor = (b as { anchor: Anchor }).anchor;
317
- return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash;
300
+ return aAnchor.line === bAnchor.line;
318
301
  }
319
302
 
320
303
  /**
@@ -606,13 +589,13 @@ function absorbReplacementBoundaryDuplicates(
606
589
  }
607
590
 
608
591
  for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) {
609
- absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
592
+ absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++));
610
593
  }
611
594
  for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) {
612
595
  absorbed.push(edits[groupIndex]);
613
596
  }
614
597
  for (const line of contiguousRange(endLine + 1, safeSuffixCount)) {
615
- absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
598
+ absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++));
616
599
  }
617
600
 
618
601
  index = group.endIndex;
@@ -653,8 +636,7 @@ export function applyHashlineEdits(
653
636
  if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
654
637
  };
655
638
 
656
- const mismatches = validateHashlineAnchors(edits, fileLines);
657
- if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
639
+ validateHashlineLineBounds(edits, fileLines);
658
640
 
659
641
  const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
660
642
 
@@ -669,10 +651,9 @@ export function applyHashlineEdits(
669
651
  continue;
670
652
  }
671
653
  const nextLineNum = anchorLine + 1;
672
- const nextContent = fileLines[nextLineNum - 1] ?? "";
673
654
  edit.cursor = {
674
655
  kind: "before_anchor",
675
- anchor: { line: nextLineNum, hash: computeLineHash(nextLineNum, nextContent) },
656
+ anchor: { line: nextLineNum },
676
657
  };
677
658
  }
678
659
 
@@ -711,6 +692,23 @@ export function applyHashlineEdits(
711
692
  }
712
693
  if (beforeLines.length === 0 && !deleteLine) continue;
713
694
 
695
+ const replaceMode = beforeLines.length > 0;
696
+ if (deleteLine && !replaceMode) {
697
+ const balance = computeDelimiterBalance([currentLine]);
698
+ const trimmedCurrentLine = currentLine.trim();
699
+ const touchesStructuralBoundary =
700
+ trimmedCurrentLine.startsWith(")") ||
701
+ trimmedCurrentLine.startsWith("]") ||
702
+ trimmedCurrentLine.startsWith("}") ||
703
+ trimmedCurrentLine.endsWith("(") ||
704
+ trimmedCurrentLine.endsWith("[") ||
705
+ trimmedCurrentLine.endsWith("{");
706
+ if (balance.paren !== 0 || balance.bracket !== 0 || balance.brace !== 0 || touchesStructuralBoundary) {
707
+ warnings.push(
708
+ `Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use 'A:<replacement>' to keep the boundary intact.`,
709
+ );
710
+ }
711
+ }
714
712
  const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
715
713
  const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
716
714
  if (!deleteLine) {
@@ -1,9 +1,6 @@
1
1
  /** Lines of context shown either side of a hash mismatch. */
2
2
  export const MISMATCH_CONTEXT = 2;
3
3
 
4
- /** Filler hash used for the interior of a multi-line range; not validated. */
5
- export const RANGE_INTERIOR_HASH = "**";
6
-
7
4
  /** Optional patch envelope start marker; silently consumed when present. */
8
5
  export const BEGIN_PATCH_MARKER = "*** Begin Patch";
9
6
 
@@ -1,4 +1,3 @@
1
- import { computeLineHash, HL_BODY_SEP } from "./hash";
2
1
  import type { CompactHashlineDiffOptions, CompactHashlineDiffPreview } from "./types";
3
2
 
4
3
  export function buildCompactHashlineDiffPreview(
@@ -11,7 +10,7 @@ export function buildCompactHashlineDiffPreview(
11
10
 
12
11
  // `generateDiffString` numbers `+` lines with the post-edit line number,
13
12
  // `-` lines with the pre-edit line number, and context lines with the
14
- // pre-edit line number. To emit fresh anchors usable for follow-up edits,
13
+ // pre-edit line number. To emit fresh line numbers usable for follow-up edits,
15
14
  // we convert context-line numbers to post-edit positions by tracking the
16
15
  // running offset (added so far - removed so far) as we walk the diff.
17
16
  const formatted = lines.map(line => {
@@ -28,13 +27,13 @@ export function buildCompactHashlineDiffPreview(
28
27
  switch (kind) {
29
28
  case "+":
30
29
  addedLines++;
31
- return `+${lineNumber}${computeLineHash(lineNumber, content)}${HL_BODY_SEP}${content}`;
30
+ return `+${lineNumber}:${content}`;
32
31
  case "-":
33
32
  removedLines++;
34
- return `-${lineNumber}--${HL_BODY_SEP}${content}`;
33
+ return `-${lineNumber}:${content}`;
35
34
  default: {
36
35
  const newLineNumber = lineNumber + addedLines - removedLines;
37
- return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HL_BODY_SEP}${content}`;
36
+ return ` ${newLineNumber}:${content}`;
38
37
  }
39
38
  }
40
39
  });
@@ -3,9 +3,10 @@ import { normalizeToLF, stripBom } from "../edit/normalize";
3
3
  import { readEditFileText } from "../edit/read-file";
4
4
  import { resolveToCwd } from "../tools/path-utils";
5
5
  import { applyHashlineEdits } from "./apply";
6
- import { type HashlineInputSection, splitHashlineInputs } from "./input";
7
- import { parseHashline } from "./parser";
8
- import type { HashlineApplyOptions } from "./types";
6
+ import { parseHashline } from "./executor";
7
+ import { computeFileHash } from "./hash";
8
+ import { splitHashlineInputs } from "./input";
9
+ import type { HashlineApplyOptions, HashlineEdit, HashlineInputSection } from "./types";
9
10
 
10
11
  async function readHashlineFileText(
11
12
  _file: { text(): Promise<string> },
@@ -20,6 +21,28 @@ async function readHashlineFileText(
20
21
  }
21
22
  }
22
23
 
24
+ function hasAnchorScopedEdit(edits: readonly HashlineEdit[]): boolean {
25
+ return edits.some(edit => {
26
+ if (edit.kind === "delete") return true;
27
+ return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
28
+ });
29
+ }
30
+
31
+ function validateSectionHash(
32
+ section: HashlineInputSection,
33
+ text: string,
34
+ edits: readonly HashlineEdit[],
35
+ ): string | null {
36
+ if (section.fileHash === undefined) {
37
+ return hasAnchorScopedEdit(edits)
38
+ ? `Missing hashline file hash for anchored edit to ${section.path}; use \`¶${section.path}#hash\` from your latest read.`
39
+ : null;
40
+ }
41
+ const currentHash = computeFileHash(text);
42
+ if (currentHash === section.fileHash) return null;
43
+ return `Hashline file hash mismatch for ${section.path}: section is bound to #${section.fileHash}, but current file hashes to #${currentHash}; re-read and try again.`;
44
+ }
45
+
23
46
  export async function computeHashlineSectionDiff(
24
47
  section: HashlineInputSection,
25
48
  cwd: string,
@@ -30,7 +53,10 @@ export async function computeHashlineSectionDiff(
30
53
  const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
31
54
  const { text: content } = stripBom(rawContent);
32
55
  const normalized = normalizeToLF(content);
33
- const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
56
+ const { edits } = parseHashline(section.diff);
57
+ const hashError = validateSectionHash(section, normalized, edits);
58
+ if (hashError) return { error: hashError };
59
+ const result = applyHashlineEdits(normalized, edits, options);
34
60
  if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
35
61
  return generateDiffString(normalized, result.lines);
36
62
  } catch (err) {
@@ -13,13 +13,15 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"
13
13
  import { HashlineMismatchError } from "./anchors";
14
14
  import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
15
15
  import { buildCompactHashlineDiffPreview } from "./diff-preview";
16
- import { type HashlineInputSection, splitHashlineInputs } from "./input";
17
- import { parseHashlineWithWarnings } from "./parser";
16
+ import { parseHashline } from "./executor";
17
+ import { computeFileHash } from "./hash";
18
+ import { splitHashlineInputs } from "./input";
18
19
  import { tryRecoverHashlineWithCache } from "./recovery";
19
20
  import type {
20
21
  ExecuteHashlineSingleOptions,
21
22
  HashlineApplyOptions,
22
23
  HashlineEdit,
24
+ HashlineInputSection,
23
25
  hashlineEditParamsSchema,
24
26
  } from "./types";
25
27
 
@@ -46,6 +48,27 @@ function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
46
48
  });
47
49
  }
48
50
 
51
+ function collectAnchorLines(edits: HashlineEdit[]): number[] {
52
+ const lines = new Set<number>();
53
+ for (const edit of edits) {
54
+ if (edit.kind === "delete") {
55
+ lines.add(edit.anchor.line);
56
+ continue;
57
+ }
58
+ if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
59
+ lines.add(edit.cursor.anchor.line);
60
+ }
61
+ }
62
+ return [...lines].sort((a, b) => a - b);
63
+ }
64
+
65
+ function assertSectionHashAllowed(sectionPath: string, fileHash: string | undefined, edits: HashlineEdit[]): void {
66
+ if (fileHash !== undefined || !hasAnchorScopedEdit(edits)) return;
67
+ throw new Error(
68
+ `Missing hashline file hash for anchored edit to ${sectionPath}; use \`¶${sectionPath}#hash\` from your latest read.`,
69
+ );
70
+ }
71
+
49
72
  function formatNoChangeDiagnostic(pathText: string): string {
50
73
  return `Edits to ${pathText} resulted in no changes being made.`;
51
74
  }
@@ -65,36 +88,48 @@ function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetai
65
88
  }
66
89
 
67
90
  /**
68
- * Apply hashline edits with anchor-stale recovery: on `HashlineMismatchError`,
69
- * consult the read-snapshot cache for the file and 3-way-merge the edits onto
70
- * the current text. If recovery succeeds, return the merged result with a
71
- * synthetic warning. Otherwise re-throw the original mismatch error.
91
+ * Apply hashline edits with file-hash stale recovery. The section hash gates
92
+ * line-number edits against the version shown to the model; if the live file
93
+ * drifted, snapshot recovery attempts a strict 3-way merge.
72
94
  */
73
95
  function applyHashlineEditsWithRecovery(
74
96
  session: ToolSession,
75
97
  absolutePath: string,
98
+ pathText: string,
76
99
  text: string,
100
+ fileHash: string | undefined,
77
101
  edits: HashlineEdit[],
78
102
  options: HashlineApplyOptions,
79
103
  ): HashlineApplyResult {
80
- try {
81
- return applyHashlineEdits(text, edits, options);
82
- } catch (err) {
83
- if (!(err instanceof HashlineMismatchError)) throw err;
84
- const recovered = tryRecoverHashlineWithCache({
85
- cache: getFileReadCache(session),
86
- absolutePath,
87
- currentText: text,
88
- edits,
89
- options,
90
- });
91
- if (!recovered) throw err;
104
+ if (fileHash === undefined) return applyHashlineEdits(text, edits, options);
105
+
106
+ const currentHash = computeFileHash(text);
107
+ if (currentHash === fileHash) return applyHashlineEdits(text, edits, options);
108
+
109
+ const cache = getFileReadCache(session);
110
+ const recovered = tryRecoverHashlineWithCache({
111
+ cache,
112
+ absolutePath,
113
+ currentText: text,
114
+ fileHash,
115
+ edits,
116
+ options,
117
+ });
118
+ if (recovered) {
92
119
  return {
93
120
  lines: recovered.lines,
94
121
  firstChangedLine: recovered.firstChangedLine,
95
122
  warnings: recovered.warnings,
96
123
  };
97
124
  }
125
+
126
+ throw new HashlineMismatchError({
127
+ path: pathText,
128
+ expectedFileHash: fileHash,
129
+ actualFileHash: currentHash,
130
+ fileLines: text.split("\n"),
131
+ anchorLines: collectAnchorLines(edits),
132
+ });
98
133
  }
99
134
 
100
135
  /**
@@ -103,10 +138,11 @@ function applyHashlineEditsWithRecovery(
103
138
  * any changes in a multi-section batch.
104
139
  */
105
140
  async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise<void> {
106
- const { session, path: sectionPath, diff } = options;
141
+ const { session, path: sectionPath, fileHash, diff } = options;
107
142
 
108
143
  const absolutePath = resolvePlanPath(session, sectionPath);
109
- const { edits } = parseHashlineWithWarnings(diff);
144
+ const { edits } = parseHashline(diff);
145
+ assertSectionHashAllowed(sectionPath, fileHash, edits);
110
146
  enforcePlanModeWrite(session, sectionPath, { op: "update" });
111
147
 
112
148
  const source = await readHashlineFile(absolutePath, sectionPath);
@@ -118,7 +154,9 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
118
154
  const result = applyHashlineEditsWithRecovery(
119
155
  session,
120
156
  absolutePath,
157
+ sectionPath,
121
158
  normalized,
159
+ source.exists ? fileHash : undefined,
122
160
  edits,
123
161
  getHashlineApplyOptions(session),
124
162
  );
@@ -131,6 +169,7 @@ async function executeHashlineSection(
131
169
  const {
132
170
  session,
133
171
  path: sourcePath,
172
+ fileHash,
134
173
  diff,
135
174
  signal,
136
175
  batchRequest,
@@ -139,7 +178,8 @@ async function executeHashlineSection(
139
178
  } = options;
140
179
 
141
180
  const absolutePath = resolvePlanPath(session, sourcePath);
142
- const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
181
+ const { edits, warnings: parseWarnings } = parseHashline(diff);
182
+ assertSectionHashAllowed(sourcePath, fileHash, edits);
143
183
  enforcePlanModeWrite(session, sourcePath, { op: "update" });
144
184
 
145
185
  const source = await readHashlineFile(absolutePath, sourcePath);
@@ -152,7 +192,9 @@ async function executeHashlineSection(
152
192
  const result = applyHashlineEditsWithRecovery(
153
193
  session,
154
194
  absolutePath,
195
+ sourcePath,
155
196
  originalNormalized,
197
+ source.exists ? fileHash : undefined,
156
198
  edits,
157
199
  getHashlineApplyOptions(session),
158
200
  );
@@ -182,7 +224,10 @@ async function executeHashlineSection(
182
224
  // of the file: the model just received it back as the diff/preview. Cache
183
225
  // it so a follow-up edit anchored against this state can still recover
184
226
  // if the file is touched out-of-band before the next edit lands.
185
- getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"));
227
+ getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"), {
228
+ fullText: result.lines,
229
+ fileHash: computeFileHash(result.lines),
230
+ });
186
231
 
187
232
  const diffResult = generateDiffString(originalNormalized, result.lines);
188
233
  const meta = outputMeta()
@@ -257,11 +302,31 @@ export async function executeHashlineSingle(
257
302
  * Path order is preserved by first occurrence.
258
303
  */
259
304
  function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] {
260
- const byPath = new Map<string, string[]>();
305
+ const byPath = new Map<string, { fileHash?: string; diffs: string[] }>();
261
306
  for (const section of sections) {
262
307
  const existing = byPath.get(section.path);
263
- if (existing) existing.push(section.diff);
264
- else byPath.set(section.path, [section.diff]);
308
+ if (existing) {
309
+ if (
310
+ existing.fileHash !== undefined &&
311
+ section.fileHash !== undefined &&
312
+ existing.fileHash !== section.fileHash
313
+ ) {
314
+ throw new Error(
315
+ `Conflicting hashline file hashes for ${section.path}: #${existing.fileHash} and #${section.fileHash}. Re-read the file and retry with one current header.`,
316
+ );
317
+ }
318
+ if (existing.fileHash === undefined && section.fileHash !== undefined) existing.fileHash = section.fileHash;
319
+ existing.diffs.push(section.diff);
320
+ continue;
321
+ }
322
+ byPath.set(section.path, {
323
+ ...(section.fileHash !== undefined ? { fileHash: section.fileHash } : {}),
324
+ diffs: [section.diff],
325
+ });
265
326
  }
266
- return Array.from(byPath, ([path, diffs]) => ({ path, diff: diffs.join("\n") }));
327
+ return Array.from(byPath, ([path, entry]) => ({
328
+ path,
329
+ ...(entry.fileHash !== undefined ? { fileHash: entry.fileHash } : {}),
330
+ diff: entry.diffs.join("\n"),
331
+ }));
267
332
  }