@oh-my-pi/pi-coding-agent 13.19.0 → 14.0.2

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 (202) hide show
  1. package/CHANGELOG.md +266 -1
  2. package/package.json +86 -20
  3. package/scripts/format-prompts.ts +2 -2
  4. package/src/autoresearch/apply-contract-to-state.ts +24 -0
  5. package/src/autoresearch/contract.ts +0 -44
  6. package/src/autoresearch/dashboard.ts +1 -2
  7. package/src/autoresearch/git.ts +91 -0
  8. package/src/autoresearch/helpers.ts +49 -0
  9. package/src/autoresearch/index.ts +28 -187
  10. package/src/autoresearch/prompt.md +26 -9
  11. package/src/autoresearch/state.ts +0 -6
  12. package/src/autoresearch/tools/init-experiment.ts +202 -117
  13. package/src/autoresearch/tools/log-experiment.ts +83 -125
  14. package/src/autoresearch/tools/run-experiment.ts +48 -10
  15. package/src/autoresearch/types.ts +2 -2
  16. package/src/capability/index.ts +4 -2
  17. package/src/cli/file-processor.ts +3 -3
  18. package/src/cli/grep-cli.ts +8 -8
  19. package/src/cli/grievances-cli.ts +78 -0
  20. package/src/cli/read-cli.ts +67 -0
  21. package/src/cli/setup-cli.ts +4 -4
  22. package/src/cli/update-cli.ts +3 -3
  23. package/src/cli.ts +2 -0
  24. package/src/commands/grep.ts +6 -1
  25. package/src/commands/grievances.ts +20 -0
  26. package/src/commands/read.ts +33 -0
  27. package/src/commit/agentic/agent.ts +5 -5
  28. package/src/commit/agentic/index.ts +3 -4
  29. package/src/commit/agentic/tools/analyze-file.ts +3 -3
  30. package/src/commit/agentic/validation.ts +1 -1
  31. package/src/commit/analysis/conventional.ts +4 -4
  32. package/src/commit/analysis/summary.ts +3 -3
  33. package/src/commit/changelog/generate.ts +4 -4
  34. package/src/commit/map-reduce/map-phase.ts +4 -4
  35. package/src/commit/map-reduce/reduce-phase.ts +4 -4
  36. package/src/commit/pipeline.ts +3 -4
  37. package/src/config/prompt-templates.ts +44 -226
  38. package/src/config/resolve-config-value.ts +4 -2
  39. package/src/config/settings-schema.ts +54 -2
  40. package/src/config/settings.ts +25 -26
  41. package/src/dap/client.ts +674 -0
  42. package/src/dap/config.ts +150 -0
  43. package/src/dap/defaults.json +211 -0
  44. package/src/dap/index.ts +4 -0
  45. package/src/dap/session.ts +1255 -0
  46. package/src/dap/types.ts +600 -0
  47. package/src/debug/log-viewer.ts +3 -2
  48. package/src/discovery/builtin.ts +1 -2
  49. package/src/discovery/codex.ts +2 -2
  50. package/src/discovery/github.ts +2 -1
  51. package/src/discovery/helpers.ts +2 -2
  52. package/src/discovery/opencode.ts +2 -2
  53. package/src/edit/diff.ts +818 -0
  54. package/src/edit/index.ts +309 -0
  55. package/src/edit/line-hash.ts +67 -0
  56. package/src/edit/modes/chunk.ts +454 -0
  57. package/src/{patch → edit/modes}/hashline.ts +741 -361
  58. package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
  59. package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
  60. package/src/{patch → edit}/normalize.ts +97 -76
  61. package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
  62. package/src/exec/bash-executor.ts +4 -2
  63. package/src/exec/idle-timeout-watchdog.ts +126 -0
  64. package/src/exec/non-interactive-env.ts +5 -0
  65. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +2 -2
  66. package/src/extensibility/custom-commands/bundled/review/index.ts +2 -2
  67. package/src/extensibility/custom-commands/loader.ts +1 -2
  68. package/src/extensibility/custom-tools/loader.ts +34 -11
  69. package/src/extensibility/extensions/loader.ts +9 -4
  70. package/src/extensibility/extensions/runner.ts +24 -1
  71. package/src/extensibility/extensions/types.ts +1 -1
  72. package/src/extensibility/hooks/loader.ts +5 -6
  73. package/src/extensibility/hooks/types.ts +1 -1
  74. package/src/extensibility/plugins/doctor.ts +2 -1
  75. package/src/extensibility/slash-commands.ts +3 -7
  76. package/src/index.ts +2 -1
  77. package/src/internal-urls/docs-index.generated.ts +11 -11
  78. package/src/ipy/executor.ts +58 -17
  79. package/src/ipy/gateway-coordinator.ts +6 -4
  80. package/src/ipy/kernel.ts +45 -22
  81. package/src/ipy/runtime.ts +2 -2
  82. package/src/lsp/client.ts +7 -4
  83. package/src/lsp/clients/lsp-linter-client.ts +4 -4
  84. package/src/lsp/config.ts +2 -2
  85. package/src/lsp/defaults.json +688 -154
  86. package/src/lsp/index.ts +234 -45
  87. package/src/lsp/lspmux.ts +2 -2
  88. package/src/lsp/startup-events.ts +13 -0
  89. package/src/lsp/types.ts +12 -1
  90. package/src/lsp/utils.ts +8 -1
  91. package/src/main.ts +102 -46
  92. package/src/memories/index.ts +4 -5
  93. package/src/modes/acp/acp-agent.ts +563 -163
  94. package/src/modes/acp/acp-event-mapper.ts +9 -1
  95. package/src/modes/acp/acp-mode.ts +4 -2
  96. package/src/modes/components/agent-dashboard.ts +3 -4
  97. package/src/modes/components/diff.ts +6 -7
  98. package/src/modes/components/read-tool-group.ts +6 -12
  99. package/src/modes/components/settings-defs.ts +5 -0
  100. package/src/modes/components/tool-execution.ts +1 -1
  101. package/src/modes/components/welcome.ts +1 -1
  102. package/src/modes/controllers/btw-controller.ts +2 -2
  103. package/src/modes/controllers/command-controller.ts +3 -2
  104. package/src/modes/controllers/input-controller.ts +12 -8
  105. package/src/modes/index.ts +20 -2
  106. package/src/modes/interactive-mode.ts +94 -37
  107. package/src/modes/rpc/host-tools.ts +186 -0
  108. package/src/modes/rpc/rpc-client.ts +178 -13
  109. package/src/modes/rpc/rpc-mode.ts +73 -3
  110. package/src/modes/rpc/rpc-types.ts +53 -1
  111. package/src/modes/theme/theme.ts +80 -8
  112. package/src/modes/types.ts +2 -2
  113. package/src/prompts/system/system-prompt.md +2 -1
  114. package/src/prompts/tools/chunk-edit.md +219 -0
  115. package/src/prompts/tools/debug.md +43 -0
  116. package/src/prompts/tools/grep.md +3 -0
  117. package/src/prompts/tools/lsp.md +5 -5
  118. package/src/prompts/tools/read-chunk.md +17 -0
  119. package/src/prompts/tools/read.md +19 -5
  120. package/src/sdk.ts +190 -154
  121. package/src/secrets/obfuscator.ts +1 -1
  122. package/src/session/agent-session.ts +306 -256
  123. package/src/session/agent-storage.ts +12 -12
  124. package/src/session/compaction/branch-summarization.ts +3 -3
  125. package/src/session/compaction/compaction.ts +5 -6
  126. package/src/session/compaction/utils.ts +3 -3
  127. package/src/session/history-storage.ts +62 -19
  128. package/src/session/messages.ts +3 -3
  129. package/src/session/session-dump-format.ts +203 -0
  130. package/src/session/session-storage.ts +4 -2
  131. package/src/session/streaming-output.ts +1 -1
  132. package/src/session/tool-choice-queue.ts +213 -0
  133. package/src/slash-commands/builtin-registry.ts +56 -8
  134. package/src/ssh/connection-manager.ts +2 -2
  135. package/src/ssh/sshfs-mount.ts +5 -5
  136. package/src/stt/downloader.ts +4 -4
  137. package/src/stt/recorder.ts +4 -4
  138. package/src/stt/transcriber.ts +2 -2
  139. package/src/system-prompt.ts +21 -13
  140. package/src/task/agents.ts +5 -6
  141. package/src/task/commands.ts +2 -5
  142. package/src/task/executor.ts +4 -4
  143. package/src/task/index.ts +3 -4
  144. package/src/task/template.ts +2 -2
  145. package/src/task/worktree.ts +4 -4
  146. package/src/tools/ask.ts +2 -3
  147. package/src/tools/ast-edit.ts +7 -7
  148. package/src/tools/ast-grep.ts +7 -7
  149. package/src/tools/auto-generated-guard.ts +36 -41
  150. package/src/tools/await-tool.ts +2 -2
  151. package/src/tools/bash.ts +5 -23
  152. package/src/tools/browser.ts +4 -5
  153. package/src/tools/calculator.ts +2 -3
  154. package/src/tools/cancel-job.ts +2 -2
  155. package/src/tools/checkpoint.ts +3 -3
  156. package/src/tools/debug.ts +1007 -0
  157. package/src/tools/exit-plan-mode.ts +2 -3
  158. package/src/tools/fetch.ts +67 -3
  159. package/src/tools/find.ts +4 -5
  160. package/src/tools/fs-cache-invalidation.ts +5 -0
  161. package/src/tools/gemini-image.ts +13 -5
  162. package/src/tools/gh.ts +10 -11
  163. package/src/tools/grep.ts +57 -9
  164. package/src/tools/index.ts +44 -22
  165. package/src/tools/inspect-image.ts +4 -4
  166. package/src/tools/output-meta.ts +1 -1
  167. package/src/tools/python.ts +19 -6
  168. package/src/tools/read.ts +198 -67
  169. package/src/tools/render-mermaid.ts +2 -3
  170. package/src/tools/render-utils.ts +20 -6
  171. package/src/tools/renderers.ts +3 -1
  172. package/src/tools/report-tool-issue.ts +80 -0
  173. package/src/tools/resolve.ts +70 -39
  174. package/src/tools/search-tool-bm25.ts +2 -2
  175. package/src/tools/ssh.ts +2 -2
  176. package/src/tools/todo-write.ts +2 -2
  177. package/src/tools/tool-timeouts.ts +1 -0
  178. package/src/tools/write.ts +5 -6
  179. package/src/tui/tree-list.ts +3 -1
  180. package/src/utils/clipboard.ts +80 -0
  181. package/src/utils/commit-message-generator.ts +2 -3
  182. package/src/utils/edit-mode.ts +49 -0
  183. package/src/utils/file-display-mode.ts +6 -5
  184. package/src/utils/file-mentions.ts +8 -7
  185. package/src/utils/git.ts +4 -4
  186. package/src/utils/image-loading.ts +98 -0
  187. package/src/utils/title-generator.ts +2 -3
  188. package/src/utils/tools-manager.ts +6 -6
  189. package/src/web/scrapers/choosealicense.ts +1 -1
  190. package/src/web/search/index.ts +3 -3
  191. package/src/autoresearch/command-initialize.md +0 -34
  192. package/src/patch/diff.ts +0 -433
  193. package/src/patch/index.ts +0 -888
  194. package/src/patch/parser.ts +0 -532
  195. package/src/patch/types.ts +0 -292
  196. package/src/prompts/agents/oracle.md +0 -77
  197. package/src/tools/pending-action.ts +0 -49
  198. package/src/utils/child-process.ts +0 -88
  199. package/src/utils/frontmatter.ts +0 -117
  200. package/src/utils/image-input.ts +0 -274
  201. package/src/utils/mime.ts +0 -53
  202. package/src/utils/prompt-format.ts +0 -170
@@ -12,7 +12,33 @@
12
12
  * Reference format: `"LINENUM#HASH"` (e.g. `"5#aa"`)
13
13
  */
14
14
 
15
- import type { HashMismatch } from "./types";
15
+ import * as fs from "node:fs/promises";
16
+ import * as nodePath from "node:path";
17
+ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
18
+ import { isEnoent } from "@oh-my-pi/pi-utils";
19
+ import { type Static, Type } from "@sinclair/typebox";
20
+ import type { BunFile } from "bun";
21
+ import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
22
+ import type { ToolSession } from "../../tools";
23
+ import { assertEditableFileContent } from "../../tools/auto-generated-guard";
24
+ import {
25
+ invalidateFsScanAfterDelete,
26
+ invalidateFsScanAfterRename,
27
+ invalidateFsScanAfterWrite,
28
+ } from "../../tools/fs-cache-invalidation";
29
+ import { outputMeta } from "../../tools/output-meta";
30
+ import { resolveToCwd } from "../../tools/path-utils";
31
+ import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
32
+ import { generateDiffString } from "../diff";
33
+ import { computeLineHash, formatLineHash } from "../line-hash";
34
+ import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
35
+ import type { EditToolDetails, LspBatchRequest } from "../renderer";
36
+
37
+ export interface HashMismatch {
38
+ line: number;
39
+ expected: string;
40
+ actual: string;
41
+ }
16
42
 
17
43
  export type Anchor = { line: number; hash: string };
18
44
  export type HashlineEdit =
@@ -23,64 +49,188 @@ export type HashlineEdit =
23
49
  | { op: "append_file"; lines: string[] }
24
50
  | { op: "prepend_file"; lines: string[] };
25
51
 
26
- const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
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}:/;
54
+ const DIFF_PLUS_RE = /^[+](?![+])/;
55
+
56
+ type LinePrefixStats = {
57
+ nonEmpty: number;
58
+ hashPrefixCount: number;
59
+ diffPlusHashPrefixCount: number;
60
+ diffPlusCount: number;
61
+ };
62
+
63
+ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
64
+ const stats: LinePrefixStats = {
65
+ nonEmpty: 0,
66
+ hashPrefixCount: 0,
67
+ diffPlusHashPrefixCount: 0,
68
+ diffPlusCount: 0,
69
+ };
27
70
 
28
- const DICT = Array.from({ length: 256 }, (_, i) => {
29
- const h = i >>> 4;
30
- const l = i & 0x0f;
31
- return `${NIBBLE_STR[h]}${NIBBLE_STR[l]}`;
32
- });
71
+ for (const line of lines) {
72
+ if (line.length === 0) continue;
73
+ stats.nonEmpty++;
74
+ if (HASHLINE_PREFIX_RE.test(line)) stats.hashPrefixCount++;
75
+ if (HASHLINE_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
76
+ if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++;
77
+ }
33
78
 
34
- const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
79
+ return stats;
80
+ }
35
81
 
36
- /**
37
- * Compute a short hexadecimal hash of a single line.
38
- *
39
- * Uses xxHash32 on a trailing-whitespace-trimmed, CR-stripped line, truncated to 2 chars from
40
- * {@link NIBBLE_STR}. For lines containing no alphanumeric characters (only
41
- * punctuation/symbols/whitespace), the line number is mixed in to reduce hash collisions.
42
- * The line input should not include a trailing newline.
43
- */
44
- export function computeLineHash(idx: number, line: string): string {
45
- line = line.replace(/\r/g, "").trimEnd();
82
+ export function stripNewLinePrefixes(lines: string[]): string[] {
83
+ const { nonEmpty, hashPrefixCount, diffPlusHashPrefixCount, diffPlusCount } = collectLinePrefixStats(lines);
84
+ if (nonEmpty === 0) return lines;
85
+
86
+ const stripHash = hashPrefixCount > 0 && hashPrefixCount === nonEmpty;
87
+ const stripPlus =
88
+ !stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
89
+ if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines;
90
+
91
+ return lines.map(line => {
92
+ if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "");
93
+ if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
94
+ if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
95
+ return line.replace(HASHLINE_PREFIX_RE, "");
96
+ }
97
+ return line;
98
+ });
99
+ }
100
+
101
+ export function stripHashlinePrefixes(lines: string[]): string[] {
102
+ const { nonEmpty, hashPrefixCount } = collectLinePrefixStats(lines);
103
+ if (nonEmpty === 0 || hashPrefixCount !== nonEmpty) return lines;
104
+ return lines.map(line => line.replace(HASHLINE_PREFIX_RE, ""));
105
+ }
106
+
107
+ const linesSchema = Type.Union([
108
+ Type.Array(Type.String(), { description: "content (preferred format)" }),
109
+ Type.String(),
110
+ Type.Null(),
111
+ ]);
112
+
113
+ const locSchema = Type.Union(
114
+ [
115
+ Type.Literal("append"),
116
+ Type.Literal("prepend"),
117
+ Type.Object({ append: Type.String({ description: "anchor" }) }),
118
+ Type.Object({ prepend: Type.String({ description: "anchor" }) }),
119
+ Type.Object({
120
+ range: Type.Object({
121
+ pos: Type.String({ description: "first line to edit (inclusive)" }),
122
+ end: Type.String({ description: "last line to edit (inclusive)" }),
123
+ }),
124
+ }),
125
+ ],
126
+ { description: "insert location" },
127
+ );
128
+
129
+ export const hashlineEditSchema = Type.Object(
130
+ {
131
+ loc: locSchema,
132
+ content: linesSchema,
133
+ },
134
+ { additionalProperties: false },
135
+ );
136
+
137
+ export const hashlineEditParamsSchema = Type.Object(
138
+ {
139
+ path: Type.String({ description: "path" }),
140
+ edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }),
141
+ delete: Type.Optional(Type.Boolean({ description: "If true, delete $path" })),
142
+ move: Type.Optional(Type.String({ description: "If set, move $path to $move" })),
143
+ },
144
+ { additionalProperties: false },
145
+ );
146
+
147
+ export type HashlineToolEdit = Static<typeof hashlineEditSchema>;
148
+ export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
149
+
150
+ interface ExecuteHashlineModeOptions {
151
+ session: ToolSession;
152
+ params: HashlineParams;
153
+ signal?: AbortSignal;
154
+ batchRequest?: LspBatchRequest;
155
+ writethrough: WritethroughCallback;
156
+ beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
157
+ }
46
158
 
47
- let seed = 0;
48
- if (!RE_SIGNIFICANT.test(line)) {
49
- seed = idx;
159
+ export function hashlineParseText(edit: string[] | string | null): string[] {
160
+ if (edit === null) return [];
161
+ if (typeof edit === "string") {
162
+ const normalizedEdit = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
163
+ edit = normalizedEdit.replaceAll("\r", "").split("\n");
50
164
  }
51
- return DICT[Bun.hash.xxHash32(line, seed) & 0xff];
165
+ return stripNewLinePrefixes(edit);
52
166
  }
53
167
 
54
- /**
55
- * Formats a tag given the line number and text.
56
- */
57
- export function formatLineTag(line: number, lines: string): string {
58
- return `${line}#${computeLineHash(line, lines)}`;
168
+ export function isHashlineParams(params: unknown): params is HashlineParams {
169
+ return (
170
+ typeof params === "object" &&
171
+ params !== null &&
172
+ "edits" in params &&
173
+ Array.isArray(params.edits) &&
174
+ (params.edits.length === 0 ||
175
+ (typeof params.edits[0] === "object" && params.edits[0] !== null && "loc" in params.edits[0]))
176
+ );
59
177
  }
60
178
 
61
- /**
62
- * Format file text with hashline prefixes for display.
63
- *
64
- * Each line becomes `LINENUM#HASH:TEXT` where LINENUM is 1-indexed.
65
- *
66
- * @param text - Raw file text string
67
- * @param startLine - First line number (1-indexed, defaults to 1)
68
- * @returns Formatted string with one hashline-prefixed line per input line
69
- *
70
- * @example
71
- * ```
72
- * formatHashLines("function hi() {\n return;\n}")
73
- * // "1#HH:function hi() {\n2#HH: return;\n3#HH:}"
74
- * ```
75
- */
76
- export function formatHashLines(text: string, startLine = 1): string {
77
- const lines = text.split("\n");
78
- return lines
79
- .map((line, i) => {
80
- const num = startLine + i;
81
- return `${formatLineTag(num, line)}:${line}`;
82
- })
83
- .join("\n");
179
+ function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
180
+ return edits.map(resolveEditAnchor);
181
+ }
182
+
183
+ function tryParseTag(raw: string): Anchor | undefined {
184
+ try {
185
+ return parseTag(raw);
186
+ } catch {
187
+ return undefined;
188
+ }
189
+ }
190
+
191
+ function requireParsedAnchor(raw: string, op: "append" | "prepend"): Anchor {
192
+ const anchor = tryParseTag(raw);
193
+ if (!anchor) throw new Error(`${op} requires a valid anchor.`);
194
+ return anchor;
195
+ }
196
+
197
+ function requireParsedRange(range: { pos: string; end: string }): { pos: Anchor; end: Anchor } {
198
+ const pos = tryParseTag(range.pos);
199
+ const end = tryParseTag(range.end);
200
+ if (!pos || !end) throw new Error("range requires valid pos and end anchors.");
201
+ return { pos, end };
202
+ }
203
+
204
+ function resolveEditAnchor(edit: HashlineToolEdit): HashlineEdit {
205
+ const lines = hashlineParseText(edit.content);
206
+ const loc = edit.loc;
207
+
208
+ if (loc === "append") {
209
+ return { op: "append_file", lines };
210
+ }
211
+
212
+ if (loc === "prepend") {
213
+ return { op: "prepend_file", lines };
214
+ }
215
+
216
+ if (typeof loc !== "object") {
217
+ throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`);
218
+ }
219
+
220
+ if ("append" in loc) {
221
+ return { op: "append_at", pos: requireParsedAnchor(loc.append, "append"), lines };
222
+ }
223
+
224
+ if ("prepend" in loc) {
225
+ return { op: "prepend_at", pos: requireParsedAnchor(loc.prepend, "prepend"), lines };
226
+ }
227
+
228
+ if ("range" in loc) {
229
+ const { pos, end } = requireParsedRange(loc.range);
230
+ return { op: "replace_range", pos, end, lines };
231
+ }
232
+
233
+ throw new Error("Unknown loc shape. Expected append, prepend, or range.");
84
234
  }
85
235
 
86
236
  // ═══════════════════════════════════════════════════════════════════════════
@@ -96,6 +246,77 @@ export interface HashlineStreamOptions {
96
246
  maxChunkBytes?: number;
97
247
  }
98
248
 
249
+ interface ResolvedHashlineStreamOptions {
250
+ startLine: number;
251
+ maxChunkLines: number;
252
+ maxChunkBytes: number;
253
+ }
254
+
255
+ type HashlineLineFormatter = (lineNumber: number, line: string) => string;
256
+
257
+ interface HashlineChunkEmitter {
258
+ pushLine: (line: string) => string[];
259
+ flush: () => string | undefined;
260
+ }
261
+
262
+ function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedHashlineStreamOptions {
263
+ return {
264
+ startLine: options.startLine ?? 1,
265
+ maxChunkLines: options.maxChunkLines ?? 200,
266
+ maxChunkBytes: options.maxChunkBytes ?? 64 * 1024,
267
+ };
268
+ }
269
+
270
+ function createHashlineChunkEmitter(
271
+ options: ResolvedHashlineStreamOptions,
272
+ formatLine: HashlineLineFormatter,
273
+ ): HashlineChunkEmitter {
274
+ let lineNumber = options.startLine;
275
+ let outLines: string[] = [];
276
+ let outBytes = 0;
277
+
278
+ const flush = (): string | undefined => {
279
+ if (outLines.length === 0) return undefined;
280
+ const chunk = outLines.join("\n");
281
+ outLines = [];
282
+ outBytes = 0;
283
+ return chunk;
284
+ };
285
+
286
+ const pushLine = (line: string): string[] => {
287
+ const formatted = formatLine(lineNumber, line);
288
+ lineNumber++;
289
+
290
+ const chunksToYield: string[] = [];
291
+ const sepBytes = outLines.length === 0 ? 0 : 1;
292
+ const lineBytes = Buffer.byteLength(formatted, "utf-8");
293
+
294
+ if (
295
+ outLines.length > 0 &&
296
+ (outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes)
297
+ ) {
298
+ const flushed = flush();
299
+ if (flushed) chunksToYield.push(flushed);
300
+ }
301
+
302
+ outLines.push(formatted);
303
+ outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes;
304
+
305
+ if (outLines.length >= options.maxChunkLines || outBytes >= options.maxChunkBytes) {
306
+ const flushed = flush();
307
+ if (flushed) chunksToYield.push(flushed);
308
+ }
309
+
310
+ return chunksToYield;
311
+ };
312
+
313
+ return { pushLine, flush };
314
+ }
315
+
316
+ function formatHashlineStreamLine(lineNumber: number, line: string): string {
317
+ return `${formatLineHash(lineNumber, line)}:${line}`;
318
+ }
319
+
99
320
  function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
100
321
  return (
101
322
  typeof value === "object" &&
@@ -129,52 +350,13 @@ export async function* streamHashLinesFromUtf8(
129
350
  source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
130
351
  options: HashlineStreamOptions = {},
131
352
  ): AsyncGenerator<string> {
132
- const startLine = options.startLine ?? 1;
133
- const maxChunkLines = options.maxChunkLines ?? 200;
134
- const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024;
353
+ const resolvedOptions = resolveHashlineStreamOptions(options);
135
354
  const decoder = new TextDecoder("utf-8");
136
355
  const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source;
137
- let lineNum = startLine;
138
356
  let pending = "";
139
357
  let sawAnyText = false;
140
358
  let endedWithNewline = false;
141
- let outLines: string[] = [];
142
- let outBytes = 0;
143
-
144
- const flush = (): string | undefined => {
145
- if (outLines.length === 0) return undefined;
146
- const chunk = outLines.join("\n");
147
- outLines = [];
148
- outBytes = 0;
149
- return chunk;
150
- };
151
-
152
- const pushLine = (line: string): string[] => {
153
- const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
154
- lineNum++;
155
-
156
- const chunksToYield: string[] = [];
157
- const sepBytes = outLines.length === 0 ? 0 : 1; // "\n"
158
- const lineBytes = Buffer.byteLength(formatted, "utf-8");
159
-
160
- if (
161
- outLines.length > 0 &&
162
- (outLines.length >= maxChunkLines || outBytes + sepBytes + lineBytes > maxChunkBytes)
163
- ) {
164
- const flushed = flush();
165
- if (flushed) chunksToYield.push(flushed);
166
- }
167
-
168
- outLines.push(formatted);
169
- outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes;
170
-
171
- if (outLines.length >= maxChunkLines || outBytes >= maxChunkBytes) {
172
- const flushed = flush();
173
- if (flushed) chunksToYield.push(flushed);
174
- }
175
-
176
- return chunksToYield;
177
- };
359
+ const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine);
178
360
 
179
361
  const consumeText = (text: string): string[] => {
180
362
  if (text.length === 0) return [];
@@ -187,7 +369,7 @@ export async function* streamHashLinesFromUtf8(
187
369
  const line = pending.slice(0, idx);
188
370
  pending = pending.slice(idx + 1);
189
371
  endedWithNewline = true;
190
- chunksToYield.push(...pushLine(line));
372
+ chunksToYield.push(...emitter.pushLine(line));
191
373
  }
192
374
  if (pending.length > 0) endedWithNewline = false;
193
375
  return chunksToYield;
@@ -203,17 +385,17 @@ export async function* streamHashLinesFromUtf8(
203
385
  }
204
386
  if (!sawAnyText) {
205
387
  // Mirror `"".split("\n")` behavior: one empty line.
206
- for (const out of pushLine("")) {
388
+ for (const out of emitter.pushLine("")) {
207
389
  yield out;
208
390
  }
209
391
  } else if (pending.length > 0 || endedWithNewline) {
210
392
  // Emit the final line (may be empty if the file ended with a newline).
211
- for (const out of pushLine(pending)) {
393
+ for (const out of emitter.pushLine(pending)) {
212
394
  yield out;
213
395
  }
214
396
  }
215
397
 
216
- const last = flush();
398
+ const last = emitter.flush();
217
399
  if (last) yield last;
218
400
  }
219
401
 
@@ -226,72 +408,34 @@ export async function* streamHashLinesFromLines(
226
408
  lines: Iterable<string> | AsyncIterable<string>,
227
409
  options: HashlineStreamOptions = {},
228
410
  ): AsyncGenerator<string> {
229
- const startLine = options.startLine ?? 1;
230
- const maxChunkLines = options.maxChunkLines ?? 200;
231
- const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024;
232
-
233
- let lineNum = startLine;
234
- let outLines: string[] = [];
235
- let outBytes = 0;
411
+ const resolvedOptions = resolveHashlineStreamOptions(options);
412
+ const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine);
236
413
  let sawAnyLine = false;
237
- const flush = (): string | undefined => {
238
- if (outLines.length === 0) return undefined;
239
- const chunk = outLines.join("\n");
240
- outLines = [];
241
- outBytes = 0;
242
- return chunk;
243
- };
244
-
245
- const pushLine = (line: string): string[] => {
246
- sawAnyLine = true;
247
- const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
248
- lineNum++;
249
-
250
- const chunksToYield: string[] = [];
251
- const sepBytes = outLines.length === 0 ? 0 : 1;
252
- const lineBytes = Buffer.byteLength(formatted, "utf-8");
253
-
254
- if (
255
- outLines.length > 0 &&
256
- (outLines.length >= maxChunkLines || outBytes + sepBytes + lineBytes > maxChunkBytes)
257
- ) {
258
- const flushed = flush();
259
- if (flushed) chunksToYield.push(flushed);
260
- }
261
-
262
- outLines.push(formatted);
263
- outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes;
264
-
265
- if (outLines.length >= maxChunkLines || outBytes >= maxChunkBytes) {
266
- const flushed = flush();
267
- if (flushed) chunksToYield.push(flushed);
268
- }
269
-
270
- return chunksToYield;
271
- };
272
414
 
273
415
  const asyncIterator = (lines as AsyncIterable<string>)[Symbol.asyncIterator];
274
416
  if (typeof asyncIterator === "function") {
275
417
  for await (const line of lines as AsyncIterable<string>) {
276
- for (const out of pushLine(line)) {
418
+ sawAnyLine = true;
419
+ for (const out of emitter.pushLine(line)) {
277
420
  yield out;
278
421
  }
279
422
  }
280
423
  } else {
281
424
  for (const line of lines as Iterable<string>) {
282
- for (const out of pushLine(line)) {
425
+ sawAnyLine = true;
426
+ for (const out of emitter.pushLine(line)) {
283
427
  yield out;
284
428
  }
285
429
  }
286
430
  }
287
431
  if (!sawAnyLine) {
288
432
  // Mirror `"".split("\n")` behavior: one empty line.
289
- for (const out of pushLine("")) {
433
+ for (const out of emitter.pushLine("")) {
290
434
  yield out;
291
435
  }
292
436
  }
293
437
 
294
- const last = flush();
438
+ const last = emitter.flush();
295
439
  if (last) yield last;
296
440
  }
297
441
 
@@ -454,124 +598,48 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warn
454
598
  );
455
599
  }
456
600
  }
457
- // ═══════════════════════════════════════════════════════════════════════════
458
- // Edit Application
459
- // ═══════════════════════════════════════════════════════════════════════════
460
-
461
- /**
462
- * Apply an array of hashline edits to file content.
463
- *
464
- * Each edit operation identifies target lines directly (`replace`,
465
- * `append`, `prepend`). Line references are resolved via {@link parseTag}
466
- * and hashes validated before any mutation.
467
- *
468
- * Edits are sorted bottom-up (highest effective line first) so earlier
469
- * splices don't invalidate later line numbers.
470
- *
471
- * @returns The modified content and the 1-indexed first changed line number
472
- */
473
- export function applyHashlineEdits(
474
- text: string,
475
- edits: HashlineEdit[],
476
- ): {
477
- lines: string;
478
- firstChangedLine: number | undefined;
479
- warnings?: string[];
480
- noopEdits?: Array<{ editIndex: number; loc: string; current: string }>;
481
- } {
482
- if (edits.length === 0) {
483
- return { lines: text, firstChangedLine: undefined };
484
- }
485
601
 
486
- const fileLines = text.split("\n");
487
- const originalFileLines = [...fileLines];
488
- let firstChangedLine: number | undefined;
489
- const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = [];
490
- const warnings: string[] = [];
602
+ function runHashlinePreflightSanitizers(edits: HashlineEdit[], warnings: string[]): void {
603
+ maybeAutocorrectEscapedTabIndentation(edits, warnings);
604
+ maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
605
+ }
491
606
 
492
- // Pre-validate: collect all hash mismatches before mutating
493
- const mismatches: HashMismatch[] = [];
494
- function validateRef(ref: { line: number; hash: string }): boolean {
495
- if (ref.line < 1 || ref.line > fileLines.length) {
496
- throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
497
- }
498
- const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
499
- if (actualHash === ref.hash) {
500
- return true;
501
- }
502
- mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
503
- return false;
607
+ function ensureHashlineEditHasContent(edit: HashlineEdit): void {
608
+ if (edit.lines.length === 0) {
609
+ edit.lines = [""];
504
610
  }
505
- for (const edit of edits) {
506
- switch (edit.op) {
507
- case "replace_line": {
508
- if (!validateRef(edit.pos)) continue;
509
- break;
510
- }
511
- case "replace_range": {
512
- const startValid = validateRef(edit.pos);
513
- const endValid = validateRef(edit.end);
514
- if (!startValid || !endValid) continue;
515
- if (edit.pos.line > edit.end.line) {
516
- throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
517
- }
518
- break;
519
- }
520
- case "append_at":
521
- case "prepend_at": {
522
- if (!validateRef(edit.pos)) continue;
523
- if (edit.lines.length === 0) {
524
- edit.lines = [""]; // insert an empty line
525
- }
526
- break;
527
- }
528
- case "append_file":
529
- case "prepend_file": {
530
- if (edit.lines.length === 0) {
531
- edit.lines = [""]; // insert an empty line
532
- }
533
- break;
534
- }
535
- }
611
+ }
612
+
613
+ function collectBoundaryDuplicationWarning(edit: HashlineEdit, originalFileLines: string[], warnings: string[]): void {
614
+ let endLine: number;
615
+ switch (edit.op) {
616
+ case "replace_line":
617
+ endLine = edit.pos.line;
618
+ break;
619
+ case "replace_range":
620
+ endLine = edit.end.line;
621
+ break;
622
+ default:
623
+ return;
536
624
  }
537
- if (mismatches.length > 0) {
538
- throw new HashlineMismatchError(mismatches, fileLines);
625
+
626
+ if (edit.lines.length === 0) return;
627
+ const nextSurvivingIdx = endLine;
628
+ if (nextSurvivingIdx >= originalFileLines.length) return;
629
+ const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
630
+ const lastInsertedLine = edit.lines[edit.lines.length - 1];
631
+ const trimmedNext = nextSurvivingLine.trim();
632
+ const trimmedLast = lastInsertedLine.trim();
633
+ if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
634
+ const tag = formatLineHash(endLine + 1, nextSurvivingLine);
635
+ warnings.push(
636
+ `Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
637
+ `If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
638
+ );
539
639
  }
540
- maybeAutocorrectEscapedTabIndentation(edits, warnings);
541
- maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
640
+ }
542
641
 
543
- // Warn when a replace_range/replace_line's last inserted line duplicates the next surviving line.
544
- // This catches the common boundary-overreach pattern where the agent includes a closing delimiter
545
- // in the replacement but sets `end` to the line before the delimiter, causing duplication.
546
- for (const edit of edits) {
547
- let endLine: number;
548
- switch (edit.op) {
549
- case "replace_line":
550
- endLine = edit.pos.line;
551
- break;
552
- case "replace_range":
553
- endLine = edit.end.line;
554
- break;
555
- default:
556
- continue;
557
- }
558
- if (edit.lines.length === 0) continue;
559
- const nextSurvivingIdx = endLine; // 0-indexed: endLine (1-indexed) is the next line after `end`
560
- if (nextSurvivingIdx >= originalFileLines.length) continue;
561
- const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
562
- const lastInsertedLine = edit.lines[edit.lines.length - 1];
563
- const trimmedNext = nextSurvivingLine.trim();
564
- const trimmedLast = lastInsertedLine.trim();
565
- // Only warn for non-trivial lines to avoid false positives on blank lines or bare punctuation
566
- if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
567
- const tag = formatLineTag(endLine + 1, nextSurvivingLine);
568
- warnings.push(
569
- `Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
570
- `If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
571
- );
572
- }
573
- }
574
- // Deduplicate identical edits targeting the same line(s)
642
+ function dedupeHashlineEdits(edits: HashlineEdit[]): void {
575
643
  const seenEditKeys = new Map<string, number>();
576
644
  const dedupIndices = new Set<number>();
577
645
  for (let i = 0; i < edits.length; i++) {
@@ -604,137 +672,234 @@ export function applyHashlineEdits(
604
672
  seenEditKeys.set(dstKey, i);
605
673
  }
606
674
  }
607
- if (dedupIndices.size > 0) {
608
- for (let i = edits.length - 1; i >= 0; i--) {
609
- if (dedupIndices.has(i)) edits.splice(i, 1);
610
- }
675
+ if (dedupIndices.size === 0) return;
676
+ for (let i = edits.length - 1; i >= 0; i--) {
677
+ if (dedupIndices.has(i)) edits.splice(i, 1);
611
678
  }
679
+ }
612
680
 
613
- // Compute sort key (descending) bottom-up application
614
- const annotated = edits.map((edit, idx) => {
615
- let sortLine: number;
616
- let precedence: number;
617
- switch (edit.op) {
618
- case "replace_line":
619
- sortLine = edit.pos.line;
620
- precedence = 0;
621
- break;
622
- case "replace_range":
623
- sortLine = edit.end.line;
624
- precedence = 0;
625
- break;
626
- case "append_at":
627
- sortLine = edit.pos.line;
628
- precedence = 1;
629
- break;
630
- case "prepend_at":
631
- sortLine = edit.pos.line;
632
- precedence = 2;
633
- break;
634
- case "append_file":
635
- sortLine = fileLines.length + 1;
636
- precedence = 1;
637
- break;
638
- case "prepend_file":
639
- sortLine = 0;
640
- precedence = 2;
641
- break;
642
- }
643
- return { edit, idx, sortLine, precedence };
644
- });
645
-
646
- annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
681
+ function getHashlineEditSortKey(edit: HashlineEdit, fileLineCount: number): { sortLine: number; precedence: number } {
682
+ switch (edit.op) {
683
+ case "replace_line":
684
+ return { sortLine: edit.pos.line, precedence: 0 };
685
+ case "replace_range":
686
+ return { sortLine: edit.end.line, precedence: 0 };
687
+ case "append_at":
688
+ return { sortLine: edit.pos.line, precedence: 1 };
689
+ case "prepend_at":
690
+ return { sortLine: edit.pos.line, precedence: 2 };
691
+ case "append_file":
692
+ return { sortLine: fileLineCount + 1, precedence: 1 };
693
+ case "prepend_file":
694
+ return { sortLine: 0, precedence: 2 };
695
+ }
696
+ }
647
697
 
648
- // Apply edits bottom-up
649
- for (const { edit, idx } of annotated) {
650
- switch (edit.op) {
651
- case "replace_line": {
652
- const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
653
- const newLines = edit.lines;
654
- if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
655
- noopEdits.push({
656
- editIndex: idx,
657
- loc: `${edit.pos.line}#${edit.pos.hash}`,
658
- current: origLines.join("\n"),
659
- });
660
- break;
661
- }
662
- fileLines.splice(edit.pos.line - 1, 1, ...newLines);
663
- trackFirstChanged(edit.pos.line);
664
- break;
665
- }
666
- case "replace_range": {
667
- const count = edit.end.line - edit.pos.line + 1;
668
- fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
669
- trackFirstChanged(edit.pos.line);
698
+ function applyHashlineEditToLines(
699
+ edit: HashlineEdit,
700
+ fileLines: string[],
701
+ originalFileLines: string[],
702
+ editIndex: number,
703
+ noopEdits: Array<{ editIndex: number; loc: string; current: string }>,
704
+ trackFirstChanged: (line: number) => void,
705
+ ): void {
706
+ switch (edit.op) {
707
+ case "replace_line": {
708
+ const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
709
+ const newLines = edit.lines;
710
+ if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
711
+ noopEdits.push({
712
+ editIndex,
713
+ loc: `${edit.pos.line}#${edit.pos.hash}`,
714
+ current: origLines.join("\n"),
715
+ });
670
716
  break;
671
717
  }
672
- case "append_at": {
673
- const inserted = edit.lines;
674
- if (inserted.length === 0) {
675
- noopEdits.push({
676
- editIndex: idx,
677
- loc: `${edit.pos.line}#${edit.pos.hash}`,
678
- current: originalFileLines[edit.pos.line - 1],
679
- });
680
- break;
681
- }
682
- fileLines.splice(edit.pos.line, 0, ...inserted);
683
- trackFirstChanged(edit.pos.line + 1);
718
+ fileLines.splice(edit.pos.line - 1, 1, ...newLines);
719
+ trackFirstChanged(edit.pos.line);
720
+ break;
721
+ }
722
+ case "replace_range": {
723
+ const count = edit.end.line - edit.pos.line + 1;
724
+ fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
725
+ trackFirstChanged(edit.pos.line);
726
+ break;
727
+ }
728
+ case "append_at": {
729
+ const inserted = edit.lines;
730
+ if (inserted.length === 0) {
731
+ noopEdits.push({
732
+ editIndex,
733
+ loc: `${edit.pos.line}#${edit.pos.hash}`,
734
+ current: originalFileLines[edit.pos.line - 1],
735
+ });
684
736
  break;
685
737
  }
686
- case "prepend_at": {
687
- const inserted = edit.lines;
688
- if (inserted.length === 0) {
689
- noopEdits.push({
690
- editIndex: idx,
691
- loc: `${edit.pos.line}#${edit.pos.hash}`,
692
- current: originalFileLines[edit.pos.line - 1],
693
- });
694
- break;
695
- }
696
- fileLines.splice(edit.pos.line - 1, 0, ...inserted);
697
- trackFirstChanged(edit.pos.line);
738
+ fileLines.splice(edit.pos.line, 0, ...inserted);
739
+ trackFirstChanged(edit.pos.line + 1);
740
+ break;
741
+ }
742
+ case "prepend_at": {
743
+ const inserted = edit.lines;
744
+ if (inserted.length === 0) {
745
+ noopEdits.push({
746
+ editIndex,
747
+ loc: `${edit.pos.line}#${edit.pos.hash}`,
748
+ current: originalFileLines[edit.pos.line - 1],
749
+ });
698
750
  break;
699
751
  }
700
- case "append_file": {
701
- const inserted = edit.lines;
702
- if (inserted.length === 0) {
703
- noopEdits.push({ editIndex: idx, loc: "EOF", current: "" });
704
- break;
705
- }
706
- if (fileLines.length === 1 && fileLines[0] === "") {
707
- fileLines.splice(0, 1, ...inserted);
708
- trackFirstChanged(1);
709
- } else {
710
- fileLines.splice(fileLines.length, 0, ...inserted);
711
- trackFirstChanged(fileLines.length - inserted.length + 1);
712
- }
752
+ fileLines.splice(edit.pos.line - 1, 0, ...inserted);
753
+ trackFirstChanged(edit.pos.line);
754
+ break;
755
+ }
756
+ case "append_file": {
757
+ const inserted = edit.lines;
758
+ if (inserted.length === 0) {
759
+ noopEdits.push({ editIndex, loc: "EOF", current: "" });
713
760
  break;
714
761
  }
715
- case "prepend_file": {
716
- const inserted = edit.lines;
717
- if (inserted.length === 0) {
718
- noopEdits.push({ editIndex: idx, loc: "BOF", current: "" });
719
- break;
720
- }
721
- if (fileLines.length === 1 && fileLines[0] === "") {
722
- fileLines.splice(0, 1, ...inserted);
723
- } else {
724
- fileLines.splice(0, 0, ...inserted);
725
- }
762
+ if (fileLines.length === 1 && fileLines[0] === "") {
763
+ fileLines.splice(0, 1, ...inserted);
726
764
  trackFirstChanged(1);
765
+ } else {
766
+ fileLines.splice(fileLines.length, 0, ...inserted);
767
+ trackFirstChanged(fileLines.length - inserted.length + 1);
768
+ }
769
+ break;
770
+ }
771
+ case "prepend_file": {
772
+ const inserted = edit.lines;
773
+ if (inserted.length === 0) {
774
+ noopEdits.push({ editIndex, loc: "BOF", current: "" });
727
775
  break;
728
776
  }
777
+ if (fileLines.length === 1 && fileLines[0] === "") {
778
+ fileLines.splice(0, 1, ...inserted);
779
+ } else {
780
+ fileLines.splice(0, 0, ...inserted);
781
+ }
782
+ trackFirstChanged(1);
783
+ break;
729
784
  }
730
785
  }
786
+ }
731
787
 
788
+ function buildHashlineEditResult(params: {
789
+ fileLines: string[];
790
+ firstChangedLine: number | undefined;
791
+ warnings: string[];
792
+ noopEdits: Array<{ editIndex: number; loc: string; current: string }>;
793
+ }): {
794
+ lines: string;
795
+ firstChangedLine: number | undefined;
796
+ warnings?: string[];
797
+ noopEdits?: Array<{ editIndex: number; loc: string; current: string }>;
798
+ } {
799
+ const { fileLines, firstChangedLine, warnings, noopEdits } = params;
732
800
  return {
733
801
  lines: fileLines.join("\n"),
734
802
  firstChangedLine,
735
803
  ...(warnings.length > 0 ? { warnings } : {}),
736
804
  ...(noopEdits.length > 0 ? { noopEdits } : {}),
737
805
  };
806
+ }
807
+
808
+ function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] {
809
+ const mismatches: HashMismatch[] = [];
810
+ for (const edit of edits) {
811
+ switch (edit.op) {
812
+ case "replace_line":
813
+ validateHashlineRef(edit.pos);
814
+ break;
815
+ case "replace_range":
816
+ validateHashlineRef(edit.pos);
817
+ validateHashlineRef(edit.end);
818
+ if (edit.pos.line > edit.end.line) {
819
+ throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
820
+ }
821
+ break;
822
+ case "append_at":
823
+ case "prepend_at":
824
+ validateHashlineRef(edit.pos);
825
+ ensureHashlineEditHasContent(edit);
826
+ break;
827
+ case "append_file":
828
+ case "prepend_file":
829
+ ensureHashlineEditHasContent(edit);
830
+ break;
831
+ }
832
+ }
833
+ return mismatches;
834
+
835
+ function validateHashlineRef(ref: { line: number; hash: string }): void {
836
+ if (ref.line < 1 || ref.line > fileLines.length) {
837
+ throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
838
+ }
839
+ const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
840
+ if (actualHash === ref.hash) {
841
+ return;
842
+ }
843
+ mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
844
+ }
845
+ }
846
+ // ═══════════════════════════════════════════════════════════════════════════
847
+ // Edit Application
848
+ // ═══════════════════════════════════════════════════════════════════════════
849
+
850
+ /**
851
+ * Apply an array of hashline edits to file content.
852
+ *
853
+ * Each edit operation identifies target lines directly (`replace`,
854
+ * `append`, `prepend`). Line references are resolved via {@link parseTag}
855
+ * and hashes validated before any mutation.
856
+ *
857
+ * Edits are sorted bottom-up (highest effective line first) so earlier
858
+ * splices don't invalidate later line numbers.
859
+ *
860
+ * @returns The modified content and the 1-indexed first changed line number
861
+ */
862
+ export function applyHashlineEdits(
863
+ text: string,
864
+ edits: HashlineEdit[],
865
+ ): {
866
+ lines: string;
867
+ firstChangedLine: number | undefined;
868
+ warnings?: string[];
869
+ noopEdits?: Array<{ editIndex: number; loc: string; current: string }>;
870
+ } {
871
+ if (edits.length === 0) {
872
+ return { lines: text, firstChangedLine: undefined };
873
+ }
874
+
875
+ const fileLines = text.split("\n");
876
+ const originalFileLines = [...fileLines];
877
+ let firstChangedLine: number | undefined;
878
+ const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = [];
879
+ const warnings: string[] = [];
880
+
881
+ const mismatches = validateHashlineEditRefs(edits, fileLines);
882
+ if (mismatches.length > 0) {
883
+ throw new HashlineMismatchError(mismatches, fileLines);
884
+ }
885
+ runHashlinePreflightSanitizers(edits, warnings);
886
+ for (const edit of edits) {
887
+ collectBoundaryDuplicationWarning(edit, originalFileLines, warnings);
888
+ }
889
+ dedupeHashlineEdits(edits);
890
+
891
+ const annotated = edits
892
+ .map((edit, idx) => {
893
+ const { sortLine, precedence } = getHashlineEditSortKey(edit, fileLines.length);
894
+ return { edit, idx, sortLine, precedence };
895
+ })
896
+ .sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
897
+
898
+ for (const { edit, idx } of annotated) {
899
+ applyHashlineEditToLines(edit, fileLines, originalFileLines, idx, noopEdits, trackFirstChanged);
900
+ }
901
+
902
+ return buildHashlineEditResult({ fileLines, firstChangedLine, warnings, noopEdits });
738
903
 
739
904
  function trackFirstChanged(line: number): void {
740
905
  if (firstChangedLine === undefined || line < firstChangedLine) {
@@ -959,3 +1124,218 @@ export function buildCompactHashlineDiffPreview(
959
1124
 
960
1125
  return { preview: out.join("\n"), addedLines, removedLines };
961
1126
  }
1127
+
1128
+ export async function computeHashlineDiff(
1129
+ input: { path: string; edits: HashlineEdit[]; move?: string },
1130
+ cwd: string,
1131
+ ): Promise<
1132
+ | {
1133
+ diff: string;
1134
+ firstChangedLine: number | undefined;
1135
+ }
1136
+ | {
1137
+ error: string;
1138
+ }
1139
+ > {
1140
+ const { path, edits, move } = input;
1141
+ const absolutePath = resolveToCwd(path, cwd);
1142
+ const movePath = move ? resolveToCwd(move, cwd) : undefined;
1143
+ const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
1144
+
1145
+ try {
1146
+ const file = Bun.file(absolutePath);
1147
+
1148
+ if (movePath === absolutePath) {
1149
+ return { error: "move path is the same as source path" };
1150
+ }
1151
+ if (isMoveOnly) {
1152
+ return { diff: "", firstChangedLine: undefined };
1153
+ }
1154
+
1155
+ const rawContent = await readHashlineFileText(file, path);
1156
+
1157
+ const { text: content } = stripBom(rawContent);
1158
+ const normalizedContent = normalizeToLF(content);
1159
+ const result = applyHashlineEdits(normalizedContent, edits);
1160
+ if (normalizedContent === result.lines && !move) {
1161
+ return { error: `No changes would be made to ${path}. The edits produce identical content.` };
1162
+ }
1163
+
1164
+ return generateDiffString(normalizedContent, result.lines);
1165
+ } catch (err) {
1166
+ return { error: err instanceof Error ? err.message : String(err) };
1167
+ }
1168
+ }
1169
+
1170
+ async function readHashlineFileText(file: BunFile, path: string): Promise<string> {
1171
+ try {
1172
+ return await file.text();
1173
+ } catch (error) {
1174
+ if (isEnoent(error)) {
1175
+ throw new Error(`File not found: ${path}`);
1176
+ }
1177
+ const message = error instanceof Error ? error.message : String(error);
1178
+ throw new Error(message || `Unable to read ${path}`);
1179
+ }
1180
+ }
1181
+
1182
+ export async function executeHashlineMode(
1183
+ options: ExecuteHashlineModeOptions,
1184
+ ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1185
+ const { session, params, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
1186
+ const { path, edits, delete: deleteFile, move } = params;
1187
+
1188
+ enforcePlanModeWrite(session, path, { op: deleteFile ? "delete" : "update", move });
1189
+
1190
+ if (path.endsWith(".ipynb") && edits?.length > 0) {
1191
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1192
+ }
1193
+
1194
+ const absolutePath = resolvePlanPath(session, path);
1195
+ const resolvedMove = move ? resolvePlanPath(session, move) : undefined;
1196
+ if (resolvedMove === absolutePath) {
1197
+ throw new Error("move path is the same as source path");
1198
+ }
1199
+
1200
+ const sourceFile = Bun.file(absolutePath);
1201
+ const sourceExists = await sourceFile.exists();
1202
+ const isMoveOnly = Boolean(resolvedMove) && edits.length === 0;
1203
+
1204
+ if (deleteFile) {
1205
+ if (sourceExists) {
1206
+ await sourceFile.unlink();
1207
+ }
1208
+ invalidateFsScanAfterDelete(absolutePath);
1209
+ return {
1210
+ content: [{ type: "text", text: `Deleted ${path}` }],
1211
+ details: {
1212
+ diff: "",
1213
+ op: "delete",
1214
+ meta: outputMeta().get(),
1215
+ },
1216
+ };
1217
+ }
1218
+
1219
+ if (isMoveOnly && resolvedMove) {
1220
+ if (!sourceExists) {
1221
+ throw new Error(`File not found: ${path}`);
1222
+ }
1223
+ const parentDir = nodePath.dirname(resolvedMove);
1224
+ if (parentDir && parentDir !== ".") {
1225
+ await fs.mkdir(parentDir, { recursive: true });
1226
+ }
1227
+ await fs.rename(absolutePath, resolvedMove);
1228
+ invalidateFsScanAfterRename(absolutePath, resolvedMove);
1229
+ return {
1230
+ content: [{ type: "text", text: `Moved ${path} to ${move}` }],
1231
+ details: {
1232
+ diff: "",
1233
+ op: "update",
1234
+ move,
1235
+ meta: outputMeta().get(),
1236
+ },
1237
+ };
1238
+ }
1239
+
1240
+ if (!sourceExists) {
1241
+ const lines: string[] = [];
1242
+ for (const edit of edits) {
1243
+ if (edit.loc === "append") {
1244
+ lines.push(...hashlineParseText(edit.content));
1245
+ } else if (edit.loc === "prepend") {
1246
+ lines.unshift(...hashlineParseText(edit.content));
1247
+ } else {
1248
+ throw new Error(`File not found: ${path}`);
1249
+ }
1250
+ }
1251
+
1252
+ await Bun.write(absolutePath, lines.join("\n"));
1253
+ invalidateFsScanAfterWrite(absolutePath);
1254
+ return {
1255
+ content: [{ type: "text", text: `Created ${path}` }],
1256
+ details: {
1257
+ diff: "",
1258
+ op: "create",
1259
+ meta: outputMeta().get(),
1260
+ },
1261
+ };
1262
+ }
1263
+
1264
+ const anchorEdits = resolveEditAnchors(edits);
1265
+ const rawContent = await sourceFile.text();
1266
+ assertEditableFileContent(rawContent, path);
1267
+
1268
+ const { bom, text } = stripBom(rawContent);
1269
+ const originalEnding = detectLineEnding(text);
1270
+ const originalNormalized = normalizeToLF(text);
1271
+ let normalizedText = originalNormalized;
1272
+
1273
+ const anchorResult = applyHashlineEdits(normalizedText, anchorEdits);
1274
+ normalizedText = anchorResult.lines;
1275
+
1276
+ const result = {
1277
+ text: normalizedText,
1278
+ firstChangedLine: anchorResult.firstChangedLine,
1279
+ warnings: anchorResult.warnings,
1280
+ noopEdits: anchorResult.noopEdits,
1281
+ };
1282
+ if (originalNormalized === result.text && !move) {
1283
+ let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
1284
+ if (result.noopEdits && result.noopEdits.length > 0) {
1285
+ const details = result.noopEdits
1286
+ .map(
1287
+ edit =>
1288
+ `Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}| ${edit.current}`,
1289
+ )
1290
+ .join("\n");
1291
+ diagnostic += `\n${details}`;
1292
+ if (result.noopEdits.length === 1 && result.noopEdits[0]?.current) {
1293
+ const preview = result.noopEdits[0].current.trimEnd();
1294
+ if (preview.length > 0) {
1295
+ 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.`;
1296
+ }
1297
+ }
1298
+ }
1299
+ throw new Error(diagnostic);
1300
+ }
1301
+
1302
+ const writePath = resolvedMove ?? absolutePath;
1303
+ const finalContent = bom + restoreLineEndings(result.text, originalEnding);
1304
+ const diagnostics = await writethrough(writePath, finalContent, signal, Bun.file(writePath), batchRequest, dst =>
1305
+ dst === writePath ? beginDeferredDiagnosticsForPath(writePath) : undefined,
1306
+ );
1307
+ if (resolvedMove && resolvedMove !== absolutePath) {
1308
+ await sourceFile.unlink();
1309
+ invalidateFsScanAfterRename(absolutePath, resolvedMove);
1310
+ } else {
1311
+ invalidateFsScanAfterWrite(absolutePath);
1312
+ }
1313
+
1314
+ const diffResult = generateDiffString(originalNormalized, result.text);
1315
+ const meta = outputMeta()
1316
+ .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
1317
+ .get();
1318
+
1319
+ const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`;
1320
+ const preview = buildCompactHashlineDiffPreview(diffResult.diff);
1321
+ const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`;
1322
+ const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
1323
+ const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : "";
1324
+
1325
+ return {
1326
+ content: [
1327
+ {
1328
+ type: "text",
1329
+ text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
1330
+ },
1331
+ ],
1332
+ details: {
1333
+ diff: diffResult.diff,
1334
+ firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
1335
+ diagnostics,
1336
+ op: "update",
1337
+ move,
1338
+ meta,
1339
+ },
1340
+ };
1341
+ }