open-hashline 0.4.0 → 0.6.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 (3) hide show
  1. package/README.md +16 -0
  2. package/package.json +1 -1
  3. package/src/index.ts +403 -100
package/README.md CHANGED
@@ -69,6 +69,22 @@ No modifications are made to any built-in tools. Everything works through hooks.
69
69
  { "afterHash": "7:e2c", "content": " \"newField\": \"value\"," }
70
70
  ```
71
71
 
72
+ ### 4. Multi-file edit (Codex / `apply_patch`)
73
+
74
+ Codex models use the `apply_patch` tool which accepts an `edits` array — multiple files and multiple edits per file in a single call:
75
+
76
+ ```json
77
+ {
78
+ "edits": [
79
+ { "filePath": "/project/src/config.ts", "startHash": "3:cc7", "content": " version: \"2.0.0\"," },
80
+ { "filePath": "/project/src/config.ts", "afterHash": "5:e60", "content": " debug: true," },
81
+ { "filePath": "/project/src/index.ts", "startHash": "10:a1b", "endHash": "12:f3d", "content": "// refactored" }
82
+ ]
83
+ }
84
+ ```
85
+
86
+ Edits to the same file are grouped into a single patch section with multiple `@@` chunks, sorted by line number.
87
+
72
88
  ---
73
89
 
74
90
  ## Installation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-hashline",
3
- "version": "0.4.0",
3
+ "version": "0.6.1",
4
4
  "description": "OpenCode plugin that tags lines with content hashes for reliable LLM edits",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -5,6 +5,13 @@ import * as path from "path"
5
5
 
6
6
  const z = tool.schema
7
7
 
8
+ /**
9
+ * Max line length — must match OpenCode's read tool truncation.
10
+ * Lines longer than this are displayed as `line.substring(0, 2000) + "..."`.
11
+ * We must hash the truncated form so computeFileHashes matches the read hook.
12
+ */
13
+ const MAX_LINE_LENGTH = 2000
14
+
8
15
  /**
9
16
  * djb2 hash of trimmed line content, truncated to 3 hex chars.
10
17
  * 3 hex chars = 4096 values. Collisions are rare and disambiguated by line number.
@@ -18,11 +25,46 @@ function hashLine(content: string): string {
18
25
  return (h >>> 0).toString(16).slice(-3).padStart(3, "0")
19
26
  }
20
27
 
28
+ /**
29
+ * Normalize a hash reference from model output.
30
+ * Handles: extra whitespace, trailing "|", spaces around ":"
31
+ * e.g. " 141:d81 " → "141:d81", "141:d81|" → "141:d81", "141: d81" → "141:d81"
32
+ */
33
+ function normalizeHashRef(ref: string): string {
34
+ ref = ref.trim()
35
+ if (ref.endsWith("|")) ref = ref.slice(0, -1).trimEnd()
36
+ const colonIdx = ref.indexOf(":")
37
+ if (colonIdx > 0) {
38
+ const lineNum = ref.slice(0, colonIdx).trim()
39
+ const hash = ref.slice(colonIdx + 1).trim()
40
+ return `${lineNum}:${hash}`
41
+ }
42
+ return ref
43
+ }
44
+
21
45
  /** Per-file mapping: hash ref (e.g. "42:a3f") → line content */
22
46
  const fileHashes = new Map<string, Map<string, string>>()
23
47
 
24
- /** Track file path for apply_patch hash invalidation across before/after hooks */
25
- let pendingPatchFilePath: string | undefined
48
+ /** Track file paths for apply_patch hash invalidation across before/after hooks */
49
+ let pendingPatchFilePaths: string[] = []
50
+
51
+ interface HashlineEdit {
52
+ filePath: string
53
+ startHash?: string
54
+ endHash?: string
55
+ afterHash?: string
56
+ content: string
57
+ }
58
+
59
+ /** Normalize all hash refs in an edit object */
60
+ function normalizeEdit(edit: HashlineEdit): HashlineEdit {
61
+ return {
62
+ ...edit,
63
+ startHash: edit.startHash ? normalizeHashRef(edit.startHash) : undefined,
64
+ endHash: edit.endHash ? normalizeHashRef(edit.endHash) : undefined,
65
+ afterHash: edit.afterHash ? normalizeHashRef(edit.afterHash) : undefined,
66
+ }
67
+ }
26
68
 
27
69
  export const HashlinePlugin: Plugin = async ({ directory }) => {
28
70
  function resolvePath(filePath: string): string {
@@ -30,13 +72,22 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
30
72
  return path.resolve(directory, filePath)
31
73
  }
32
74
 
33
- /** Read file from disk and compute fresh hashes */
75
+ /**
76
+ * Read file from disk and compute fresh hashes.
77
+ * Lines are truncated before hashing to match OpenCode's read tool output.
78
+ * Full (untruncated) content is stored for building oldString/patchText.
79
+ */
34
80
  function computeFileHashes(filePath: string): Map<string, string> {
35
81
  const content = fs.readFileSync(filePath, "utf-8")
36
82
  const lines = content.split("\n")
37
83
  const hashes = new Map<string, string>()
38
84
  for (let i = 0; i < lines.length; i++) {
39
- const hash = hashLine(lines[i])
85
+ // Truncate to match OpenCode's read tool (read.ts MAX_LINE_LENGTH)
86
+ const displayed =
87
+ lines[i].length > MAX_LINE_LENGTH
88
+ ? lines[i].substring(0, MAX_LINE_LENGTH) + "..."
89
+ : lines[i]
90
+ const hash = hashLine(displayed)
40
91
  hashes.set(`${i + 1}:${hash}`, lines[i])
41
92
  }
42
93
  fileHashes.set(filePath, hashes)
@@ -54,6 +105,17 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
54
105
  return undefined
55
106
  }
56
107
 
108
+ /** Find the actual hash ref for a line number (if it exists) */
109
+ function getHashRefByLineNumber(
110
+ hashes: Map<string, string>,
111
+ lineNum: number,
112
+ ): string | undefined {
113
+ for (const ref of hashes.keys()) {
114
+ if (ref.startsWith(`${lineNum}:`)) return ref
115
+ }
116
+ return undefined
117
+ }
118
+
57
119
  /** Validate a hash reference exists, re-reading file once if stale */
58
120
  function validateHash(
59
121
  filePath: string,
@@ -61,6 +123,8 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
61
123
  hashes: Map<string, string>,
62
124
  ): Map<string, string> {
63
125
  if (hashes.has(hashRef)) return hashes
126
+
127
+ // Re-read file and recompute hashes
64
128
  try {
65
129
  hashes = computeFileHashes(filePath)
66
130
  } catch {
@@ -68,13 +132,28 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
68
132
  `Cannot read file "${filePath}" to verify hash references.`,
69
133
  )
70
134
  }
71
- if (!hashes.has(hashRef)) {
72
- fileHashes.delete(filePath)
135
+ if (hashes.has(hashRef)) return hashes
136
+
137
+ // Hash not found — provide a diagnostic error message
138
+ const lineNum = parseInt(hashRef.split(":")[0], 10)
139
+ const actualRef = getHashRefByLineNumber(hashes, lineNum)
140
+
141
+ if (actualRef) {
73
142
  throw new Error(
74
- `Hash reference "${hashRef}" not found. The file may have changed since last read. Please re-read the file.`,
143
+ [
144
+ `Hash reference "${hashRef}" not found.`,
145
+ `Line ${lineNum} now has hash "${actualRef}".`,
146
+ `The file has changed since last read. Please re-read the file.`,
147
+ ].join(" "),
75
148
  )
76
149
  }
77
- return hashes
150
+ throw new Error(
151
+ [
152
+ `Hash reference "${hashRef}" not found.`,
153
+ `Line ${lineNum} does not exist in the file (${hashes.size} lines total).`,
154
+ `Please re-read the file.`,
155
+ ].join(" "),
156
+ )
78
157
  }
79
158
 
80
159
  /** Ensure hashes exist for a file, computing them if needed */
@@ -111,8 +190,118 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
111
190
  return lines
112
191
  }
113
192
 
114
- /** Build hashline parameter schema (shared between edit and apply_patch) */
115
- const hashlineParams = z.object({
193
+ /** Generate patch @@ chunk lines for a single hashline edit */
194
+ function generatePatchChunk(
195
+ filePath: string,
196
+ edit: HashlineEdit,
197
+ hashes: Map<string, string>,
198
+ ): string[] {
199
+ const chunk: string[] = []
200
+
201
+ if (edit.afterHash) {
202
+ hashes = validateHash(filePath, edit.afterHash, hashes)
203
+ const anchorContent = hashes.get(edit.afterHash)!
204
+ const anchorLine = parseInt(edit.afterHash.split(":")[0], 10)
205
+ const ctx =
206
+ anchorLine > 1
207
+ ? (getLineByNumber(hashes, anchorLine - 1) ?? "")
208
+ : ""
209
+ chunk.push(`@@ ${ctx}`)
210
+ chunk.push(` ${anchorContent}`)
211
+ for (const line of edit.content.split("\n")) {
212
+ chunk.push(`+${line}`)
213
+ }
214
+ } else if (edit.startHash) {
215
+ hashes = validateHash(filePath, edit.startHash, hashes)
216
+ if (edit.endHash) {
217
+ hashes = validateHash(filePath, edit.endHash, hashes)
218
+ }
219
+
220
+ const startLine = parseInt(edit.startHash.split(":")[0], 10)
221
+ const endLine = edit.endHash
222
+ ? parseInt(edit.endHash.split(":")[0], 10)
223
+ : startLine
224
+
225
+ if (endLine < startLine) {
226
+ throw new Error(
227
+ `endHash line (${endLine}) must be >= startHash line (${startLine})`,
228
+ )
229
+ }
230
+
231
+ const ctx =
232
+ startLine > 1
233
+ ? (getLineByNumber(hashes, startLine - 1) ?? "")
234
+ : ""
235
+ chunk.push(`@@ ${ctx}`)
236
+
237
+ const oldLines = collectRange(filePath, hashes, startLine, endLine)
238
+ for (const line of oldLines) {
239
+ chunk.push(`-${line}`)
240
+ }
241
+ for (const line of edit.content.split("\n")) {
242
+ chunk.push(`+${line}`)
243
+ }
244
+ }
245
+
246
+ return chunk
247
+ }
248
+
249
+ /**
250
+ * Apply multiple hashline edits to a file's lines array (in-place).
251
+ * Edits are sorted descending by line number and applied bottom-to-top
252
+ * so earlier indices aren't affected by insertions/deletions.
253
+ */
254
+ function applyEditsToLines(
255
+ filePath: string,
256
+ edits: HashlineEdit[],
257
+ lines: string[],
258
+ hashes: Map<string, string>,
259
+ ): void {
260
+ // Sort descending by line number (apply from bottom to top)
261
+ const sorted = [...edits].sort((a, b) => {
262
+ const lineA = parseInt(
263
+ (a.startHash || a.afterHash || "0").split(":")[0],
264
+ 10,
265
+ )
266
+ const lineB = parseInt(
267
+ (b.startHash || b.afterHash || "0").split(":")[0],
268
+ 10,
269
+ )
270
+ return lineB - lineA
271
+ })
272
+
273
+ for (const edit of sorted) {
274
+ if (edit.afterHash) {
275
+ hashes = validateHash(filePath, edit.afterHash, hashes)
276
+ const anchorLine = parseInt(edit.afterHash.split(":")[0], 10)
277
+ const newLines = edit.content.split("\n")
278
+ // Insert after anchorLine (1-indexed → splice at anchorLine)
279
+ lines.splice(anchorLine, 0, ...newLines)
280
+ } else if (edit.startHash) {
281
+ hashes = validateHash(filePath, edit.startHash, hashes)
282
+ if (edit.endHash) {
283
+ hashes = validateHash(filePath, edit.endHash, hashes)
284
+ }
285
+ const startLine = parseInt(edit.startHash.split(":")[0], 10)
286
+ const endLine = edit.endHash
287
+ ? parseInt(edit.endHash.split(":")[0], 10)
288
+ : startLine
289
+
290
+ if (endLine < startLine) {
291
+ throw new Error(
292
+ `endHash line (${endLine}) must be >= startHash line (${startLine})`,
293
+ )
294
+ }
295
+
296
+ const newLines = edit.content.split("\n")
297
+ // Replace lines startLine..endLine (1-indexed)
298
+ lines.splice(startLine - 1, endLine - startLine + 1, ...newLines)
299
+ }
300
+ }
301
+ }
302
+
303
+ /** Hashline edit schema — shared shape for individual edits */
304
+ const editShape = {
116
305
  filePath: z
117
306
  .string()
118
307
  .describe("The absolute path to the file to modify"),
@@ -137,13 +326,43 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
137
326
  content: z
138
327
  .string()
139
328
  .describe("The new content to insert or replace with"),
329
+ }
330
+
331
+ /** Schema for edit tool — edits array, single file per call */
332
+ const editParams = z.object({
333
+ edits: z
334
+ .array(z.object(editShape))
335
+ .describe(
336
+ "Array of edits to apply. All edits must target the same file.",
337
+ ),
338
+ })
339
+
340
+ /** Schema for apply_patch tool — edits array, multi-file supported */
341
+ const patchParams = z.object({
342
+ edits: z
343
+ .array(z.object(editShape))
344
+ .describe(
345
+ "Array of edits to apply. Multiple files and multiple edits per file are supported.",
346
+ ),
140
347
  })
141
348
 
142
- const hashlineDescription = [
349
+ const editDescription = [
143
350
  "Edit a file using hashline references from the most recent read output.",
144
351
  "Each line is tagged as `<line>:<hash>| <content>`.",
352
+ "Pass an `edits` array with one or more edits (all must target the same file).",
353
+ "",
354
+ "Three operations per edit:",
355
+ "1. Replace line: startHash only → replaces that single line",
356
+ "2. Replace range: startHash + endHash → replaces all lines in range",
357
+ "3. Insert after: afterHash → inserts content after that line (no replacement)",
358
+ ].join("\n")
359
+
360
+ const patchDescription = [
361
+ "Edit one or more files using hashline references from read output.",
362
+ "Each line is tagged as `<line>:<hash>| <content>`.",
363
+ "Pass an `edits` array — multiple files and multiple edits per file are supported.",
145
364
  "",
146
- "Three operations:",
365
+ "Three operations per edit:",
147
366
  "1. Replace line: startHash only → replaces that single line",
148
367
  "2. Replace range: startHash + endHash → replaces all lines in range",
149
368
  "3. Insert after: afterHash → inserts content after that line (no replacement)",
@@ -153,16 +372,27 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
153
372
  // ── Read: tag each line with its content hash ──────────────────────
154
373
  "tool.execute.after": async (input, output) => {
155
374
  if (input.tool === "edit") {
375
+ // Recompute hashes from the edited file so subsequent edits
376
+ // get diagnostic "line N now has hash X" errors instead of
377
+ // a generic "not found" when using stale refs.
156
378
  const filePath = resolvePath(input.args.filePath)
157
- fileHashes.delete(filePath)
379
+ try {
380
+ computeFileHashes(filePath)
381
+ } catch {
382
+ fileHashes.delete(filePath)
383
+ }
158
384
  return
159
385
  }
160
386
 
161
387
  if (input.tool === "apply_patch") {
162
- if (pendingPatchFilePath) {
163
- fileHashes.delete(pendingPatchFilePath)
164
- pendingPatchFilePath = undefined
388
+ for (const fp of pendingPatchFilePaths) {
389
+ try {
390
+ computeFileHashes(fp)
391
+ } catch {
392
+ fileHashes.delete(fp)
393
+ }
165
394
  }
395
+ pendingPatchFilePaths = []
166
396
  return
167
397
  }
168
398
 
@@ -212,9 +442,13 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
212
442
  // Requires PR #4956 (tool.definition hook) to take effect.
213
443
  // OpenCode shows `edit` for Anthropic models, `apply_patch` for Codex.
214
444
  "tool.definition": async (input: any, output: any) => {
215
- if (input.toolID !== "edit" && input.toolID !== "apply_patch") return
216
- output.description = hashlineDescription
217
- output.parameters = hashlineParams
445
+ if (input.toolID === "edit") {
446
+ output.description = editDescription
447
+ output.parameters = editParams
448
+ } else if (input.toolID === "apply_patch") {
449
+ output.description = patchDescription
450
+ output.parameters = patchParams
451
+ }
218
452
  },
219
453
 
220
454
  // ── System prompt: instruct the model to use hashline edits ────────
@@ -226,18 +460,25 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
226
460
  "When you read a file, each line is tagged with a hash: `<lineNumber>:<hash>| <content>`.",
227
461
  "You MUST use these hash references when editing files. Do NOT use oldString/newString or patchText.",
228
462
  "",
229
- "Three operations:",
463
+ "Pass an `edits` array with one or more edits. Each edit has: filePath, and one of:",
464
+ "",
465
+ "1. **Replace line** — `startHash` + `content`:",
466
+ ' `{ filePath: "...", startHash: "3:cc7", content: "new line" }`',
467
+ "",
468
+ "2. **Replace range** — `startHash` + `endHash` + `content`:",
469
+ ' `{ filePath: "...", startHash: "3:cc7", endHash: "5:e60", content: "line3\\nline4\\nline5" }`',
230
470
  "",
231
- "1. **Replace line** — replace a single line:",
232
- ' `startHash: "3:cc7", content: " \\"version\\": \\"1.0.0\\","` ',
471
+ "3. **Insert after** — `afterHash` + `content`:",
472
+ ' `{ filePath: "...", afterHash: "3:cc7", content: " inserted line" }`',
233
473
  "",
234
- "2. **Replace range** replace lines startHash through endHash:",
235
- ' `startHash: "3:cc7", endHash: "5:e60", content: "line3\\nline4\\nline5"`',
474
+ "Multiple edits can be batched in a single call:",
475
+ ' `{ edits: [{ filePath: "...", startHash: "3:cc7", content: "..." }, { filePath: "...", afterHash: "7:e2c", content: "..." }] }`',
236
476
  "",
237
- "3. **Insert after** insert new content after a line (without replacing it):",
238
- ' `afterHash: "3:cc7", content: " \\"newKey\\": \\"newValue\\","` ',
477
+ "IMPORTANT: The hash value (e.g. `cc7`) is the EXACT 3-character code shown after the line number and colon.",
478
+ "Copy it exactly as shown — do NOT include the `|` separator or any surrounding spaces.",
479
+ "Example: for line `42:a3f| function hello()`, the hash reference is `42:a3f` (not `42:a3f|`).",
239
480
  "",
240
- "NEVER pass oldString, newString, or patchText. ALWAYS use startHash/afterHash + content.",
481
+ "NEVER pass oldString, newString, or patchText. ALWAYS use the edits array with hash references.",
241
482
  ].join("\n"),
242
483
  )
243
484
  },
@@ -249,13 +490,82 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
249
490
  const args = output.args
250
491
 
251
492
  // Raw patchText with no hashline args → let normal patch through
252
- if (args.patchText && !args.startHash && !args.afterHash) return
493
+ if (
494
+ args.patchText &&
495
+ !args.edits &&
496
+ !args.startHash &&
497
+ !args.afterHash
498
+ )
499
+ return
500
+
501
+ // ── Multi-file edits array ──
502
+ if (args.edits && Array.isArray(args.edits)) {
503
+ // Normalize all hash refs up front
504
+ const edits = (args.edits as HashlineEdit[]).map(normalizeEdit)
505
+
506
+ // Group edits by file path (preserving order within each file)
507
+ const editsByFile = new Map<
508
+ string,
509
+ { absPath: string; relPath: string; edits: HashlineEdit[] }
510
+ >()
511
+
512
+ for (const edit of edits) {
513
+ const absPath = resolvePath(edit.filePath)
514
+ let entry = editsByFile.get(absPath)
515
+ if (!entry) {
516
+ const relPath = path
517
+ .relative(directory, absPath)
518
+ .split(path.sep)
519
+ .join("/")
520
+ entry = { absPath, relPath, edits: [] }
521
+ editsByFile.set(absPath, entry)
522
+ }
523
+ entry.edits.push(edit)
524
+ }
525
+
526
+ const patchLines: string[] = ["*** Begin Patch"]
527
+ const editedPaths: string[] = []
528
+
529
+ for (const [absPath, { relPath, edits }] of editsByFile) {
530
+ editedPaths.push(absPath)
531
+ let hashes = ensureHashes(absPath)
532
+ if (!hashes) continue
533
+
534
+ // Sort edits by line number so chunks apply top-to-bottom
535
+ edits.sort((a, b) => {
536
+ const lineA = parseInt(
537
+ (a.startHash || a.afterHash || "0").split(":")[0],
538
+ 10,
539
+ )
540
+ const lineB = parseInt(
541
+ (b.startHash || b.afterHash || "0").split(":")[0],
542
+ 10,
543
+ )
544
+ return lineA - lineB
545
+ })
546
+
547
+ // One *** Update File section with multiple @@ chunks
548
+ patchLines.push(`*** Update File: ${relPath}`)
549
+
550
+ for (const edit of edits) {
551
+ const chunkLines = generatePatchChunk(absPath, edit, hashes)
552
+ patchLines.push(...chunkLines)
553
+ }
554
+ }
253
555
 
254
- // No hashline args at all → nothing to do
556
+ patchLines.push("*** End Patch")
557
+
558
+ pendingPatchFilePaths = editedPaths
559
+ args.patchText = patchLines.join("\n")
560
+ delete args.edits
561
+ return
562
+ }
563
+
564
+ // ── Single-file fallback (backwards compat) ──
255
565
  if (!args.startHash && !args.afterHash) return
256
566
 
257
567
  const filePath = resolvePath(args.filePath)
258
- pendingPatchFilePath = filePath
568
+ pendingPatchFilePaths = [filePath]
259
569
  const relativePath = path
260
570
  .relative(directory, filePath)
261
571
  .split(path.sep)
@@ -264,68 +574,22 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
264
574
  let hashes = ensureHashes(filePath)
265
575
  if (!hashes) return
266
576
 
577
+ const edit: HashlineEdit = normalizeEdit({
578
+ filePath: args.filePath,
579
+ startHash: args.startHash,
580
+ endHash: args.endHash,
581
+ afterHash: args.afterHash,
582
+ content: args.content,
583
+ })
267
584
  const patchLines: string[] = [
268
585
  "*** Begin Patch",
269
586
  `*** Update File: ${relativePath}`,
270
587
  ]
271
-
272
- if (args.afterHash) {
273
- // ── Insert after ──
274
- hashes = validateHash(filePath, args.afterHash, hashes)
275
- const anchorContent = hashes.get(args.afterHash)!
276
- const anchorLine = parseInt(args.afterHash.split(":")[0], 10)
277
-
278
- // Use line before anchor as @@ context for positioning
279
- const ctx =
280
- anchorLine > 1
281
- ? (getLineByNumber(hashes, anchorLine - 1) ?? "")
282
- : ""
283
- patchLines.push(`@@ ${ctx}`)
284
- patchLines.push(` ${anchorContent}`) // space = keep line
285
- for (const line of args.content.split("\n")) {
286
- patchLines.push(`+${line}`)
287
- }
288
- } else {
289
- // ── Replace (single line or range) ──
290
- hashes = validateHash(filePath, args.startHash, hashes)
291
- if (args.endHash) {
292
- hashes = validateHash(filePath, args.endHash, hashes)
293
- }
294
-
295
- const startLine = parseInt(args.startHash.split(":")[0], 10)
296
- const endLine = args.endHash
297
- ? parseInt(args.endHash.split(":")[0], 10)
298
- : startLine
299
-
300
- if (endLine < startLine) {
301
- throw new Error(
302
- `endHash line (${endLine}) must be >= startHash line (${startLine})`,
303
- )
304
- }
305
-
306
- // Use line before range as @@ context for positioning
307
- const ctx =
308
- startLine > 1
309
- ? (getLineByNumber(hashes, startLine - 1) ?? "")
310
- : ""
311
- patchLines.push(`@@ ${ctx}`)
312
-
313
- // Old lines (-)
314
- const oldLines = collectRange(filePath, hashes, startLine, endLine)
315
- for (const line of oldLines) {
316
- patchLines.push(`-${line}`)
317
- }
318
-
319
- // New lines (+)
320
- for (const line of args.content.split("\n")) {
321
- patchLines.push(`+${line}`)
322
- }
323
- }
324
-
588
+ patchLines.push(...generatePatchChunk(filePath, edit, hashes))
325
589
  patchLines.push("*** End Patch")
590
+
326
591
  args.patchText = patchLines.join("\n")
327
592
 
328
- // Clean up hashline fields — apply_patch only reads patchText
329
593
  delete args.filePath
330
594
  delete args.startHash
331
595
  delete args.endHash
@@ -339,6 +603,50 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
339
603
 
340
604
  const args = output.args
341
605
 
606
+ // ── Multi-edit via edits array ──
607
+ if (args.edits && Array.isArray(args.edits)) {
608
+ // Normalize all hash refs up front
609
+ const edits = (args.edits as HashlineEdit[]).map(normalizeEdit)
610
+ if (edits.length === 0) return
611
+
612
+ // All edits must target the same file (edit tool is single-file)
613
+ const filePath = resolvePath(edits[0].filePath)
614
+ const uniqueFiles = new Set(edits.map((e) => resolvePath(e.filePath)))
615
+ if (uniqueFiles.size > 1) {
616
+ throw new Error(
617
+ "The edit tool supports one file per call. Make separate calls for each file.",
618
+ )
619
+ }
620
+
621
+ let hashes = ensureHashes(filePath)
622
+ if (!hashes) return
623
+
624
+ // Validate all hashes up front
625
+ for (const edit of edits) {
626
+ if (edit.afterHash) {
627
+ hashes = validateHash(filePath, edit.afterHash, hashes)
628
+ } else if (edit.startHash) {
629
+ hashes = validateHash(filePath, edit.startHash, hashes)
630
+ if (edit.endHash)
631
+ hashes = validateHash(filePath, edit.endHash, hashes)
632
+ }
633
+ }
634
+
635
+ // Read original file, apply all edits, produce oldString/newString
636
+ const originalContent = fs.readFileSync(filePath, "utf-8")
637
+ const lines = originalContent.split("\n")
638
+ applyEditsToLines(filePath, edits, lines, hashes)
639
+ const newContent = lines.join("\n")
640
+
641
+ args.filePath = filePath
642
+ args.oldString = originalContent
643
+ args.newString = newContent
644
+ delete args.edits
645
+ return
646
+ }
647
+
648
+ // ── Single-edit fallback (backwards compat) ──
649
+
342
650
  // Reject oldString edits for files we have hashes for — force hashline usage
343
651
  if (args.oldString && !args.startHash) {
344
652
  const filePath = resolvePath(args.filePath)
@@ -351,14 +659,18 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
351
659
  ].join(" "),
352
660
  )
353
661
  }
354
- // No hashes for this file — allow normal edit
355
662
  return
356
663
  }
357
664
 
358
665
  // Only intercept hashline edits; fall through for normal edits
359
666
  if (!args.startHash && !args.afterHash) return
360
667
 
361
- // ── Insert after: append content after the referenced line ──
668
+ // Normalize hash refs
669
+ if (args.startHash) args.startHash = normalizeHashRef(args.startHash)
670
+ if (args.endHash) args.endHash = normalizeHashRef(args.endHash)
671
+ if (args.afterHash) args.afterHash = normalizeHashRef(args.afterHash)
672
+
673
+ // Insert after
362
674
  if (args.afterHash) {
363
675
  const filePath = resolvePath(args.filePath)
364
676
  let hashes = ensureHashes(filePath)
@@ -374,11 +686,11 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
374
686
  return
375
687
  }
376
688
 
689
+ // Replace (single line or range)
377
690
  const filePath = resolvePath(args.filePath)
378
691
  let hashes = ensureHashes(filePath)
379
692
  if (!hashes) return
380
693
 
381
- // Validate startHash
382
694
  hashes = validateHash(filePath, args.startHash, hashes)
383
695
 
384
696
  const startLine = parseInt(args.startHash.split(":")[0], 10)
@@ -386,12 +698,8 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
386
698
  ? parseInt(args.endHash.split(":")[0], 10)
387
699
  : startLine
388
700
 
389
- // Validate endHash
390
- if (args.endHash && !hashes.has(args.endHash)) {
391
- fileHashes.delete(filePath)
392
- throw new Error(
393
- `Hash reference "${args.endHash}" not found. The file may have changed since last read. Please re-read the file.`,
394
- )
701
+ if (args.endHash) {
702
+ hashes = validateHash(filePath, args.endHash, hashes)
395
703
  }
396
704
 
397
705
  if (endLine < startLine) {
@@ -400,15 +708,10 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
400
708
  )
401
709
  }
402
710
 
403
- // Build oldString from the line range
404
711
  const rangeLines = collectRange(filePath, hashes, startLine, endLine)
405
- const oldString = rangeLines.join("\n")
406
-
407
- // Set resolved args for the built-in edit tool
408
- args.oldString = oldString
712
+ args.oldString = rangeLines.join("\n")
409
713
  args.newString = args.content
410
714
 
411
- // Remove hashline-specific fields so the built-in edit doesn't choke
412
715
  delete args.startHash
413
716
  delete args.endHash
414
717
  delete args.content