feat(hashline): port hashline edit tool from oh-my-pi

This PR ports the hashline edit tool from oh-my-pi to oh-my-opencode as an experimental feature.

## Features
- New experimental.hashline_edit config flag
- hashline_edit tool with 4 operations: set_line, replace_lines, insert_after, replace
- Hash-based line anchors for safe concurrent editing
- Edit tool disabler for non-OpenAI providers
- Read output enhancer with LINE:HASH prefixes
- Provider state tracking module

## Technical Details
- xxHash32-based 2-char hex hashes
- Bottom-up edit application to prevent index shifting
- OpenAI provider exemption (uses native apply_patch)
- 90 tests covering all operations and edge cases
- All files under 200 LOC limit

## Files Added/Modified
- src/tools/hashline-edit/ (7 files, ~400 LOC)
- src/hooks/hashline-edit-disabler/ (4 files, ~200 LOC)
- src/hooks/hashline-read-enhancer/ (3 files, ~400 LOC)
- src/features/hashline-provider-state.ts (13 LOC)
- src/config/schema/experimental.ts (hashline_edit flag)
- src/config/schema/hooks.ts (2 new hook names)
- src/plugin/tool-registry.ts (conditional registration)
- src/plugin/chat-params.ts (provider state tracking)
- src/tools/index.ts (export)
- src/hooks/index.ts (exports)
This commit is contained in:
YeonGyu-Kim
2026-02-16 16:32:33 +09:00
parent 149de9da66
commit 51dde4d43f
26 changed files with 1509 additions and 27 deletions
@@ -0,0 +1,321 @@
import { describe, expect, it } from "bun:test"
import {
applyHashlineEdits,
applyInsertAfter,
applyReplace,
applyReplaceLines,
applySetLine,
} from "./edit-operations"
import type { HashlineEdit, InsertAfter, Replace, ReplaceLines, SetLine } from "./types"
describe("applySetLine", () => {
it("replaces a single line at the specified anchor", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
const anchor = "2:b2" // line 2 hash
//#when
const result = applySetLine(lines, anchor, "new line 2")
//#then
expect(result).toEqual(["line 1", "new line 2", "line 3"])
})
it("handles newline escapes in replacement text", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
const anchor = "2:b2"
//#when
const result = applySetLine(lines, anchor, "new\\nline")
//#then
expect(result).toEqual(["line 1", "new\nline", "line 3"])
})
it("throws on hash mismatch", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
const anchor = "2:ff" // wrong hash
//#when / #then
expect(() => applySetLine(lines, anchor, "new")).toThrow("Hash mismatch")
})
it("throws on out of bounds line", () => {
//#given
const lines = ["line 1", "line 2"]
const anchor = "5:00"
//#when / #then
expect(() => applySetLine(lines, anchor, "new")).toThrow("out of bounds")
})
})
describe("applyReplaceLines", () => {
it("replaces a range of lines", () => {
//#given
const lines = ["line 1", "line 2", "line 3", "line 4", "line 5"]
const startAnchor = "2:b2"
const endAnchor = "4:5f"
//#when
const result = applyReplaceLines(lines, startAnchor, endAnchor, "replacement")
//#then
expect(result).toEqual(["line 1", "replacement", "line 5"])
})
it("handles newline escapes in replacement text", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
const startAnchor = "2:b2"
const endAnchor = "2:b2"
//#when
const result = applyReplaceLines(lines, startAnchor, endAnchor, "a\\nb")
//#then
expect(result).toEqual(["line 1", "a", "b", "line 3"])
})
it("throws on start hash mismatch", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
const startAnchor = "2:ff"
const endAnchor = "3:83"
//#when / #then
expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow(
"Hash mismatch"
)
})
it("throws on end hash mismatch", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
const startAnchor = "2:b2"
const endAnchor = "3:ff"
//#when / #then
expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow(
"Hash mismatch"
)
})
it("throws when start > end", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
const startAnchor = "3:83"
const endAnchor = "2:b2"
//#when / #then
expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow(
"start line 3 cannot be greater than end line 2"
)
})
})
describe("applyInsertAfter", () => {
it("inserts text after the specified line", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
const anchor = "2:b2"
//#when
const result = applyInsertAfter(lines, anchor, "inserted")
//#then
expect(result).toEqual(["line 1", "line 2", "inserted", "line 3"])
})
it("handles newline escapes to insert multiple lines", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
const anchor = "2:b2"
//#when
const result = applyInsertAfter(lines, anchor, "a\\nb\\nc")
//#then
expect(result).toEqual(["line 1", "line 2", "a", "b", "c", "line 3"])
})
it("inserts at end when anchor is last line", () => {
//#given
const lines = ["line 1", "line 2"]
const anchor = "2:b2"
//#when
const result = applyInsertAfter(lines, anchor, "inserted")
//#then
expect(result).toEqual(["line 1", "line 2", "inserted"])
})
it("throws on hash mismatch", () => {
//#given
const lines = ["line 1", "line 2"]
const anchor = "2:ff"
//#when / #then
expect(() => applyInsertAfter(lines, anchor, "new")).toThrow("Hash mismatch")
})
})
describe("applyReplace", () => {
it("replaces exact text match", () => {
//#given
const content = "hello world foo bar"
const oldText = "world"
const newText = "universe"
//#when
const result = applyReplace(content, oldText, newText)
//#then
expect(result).toEqual("hello universe foo bar")
})
it("replaces all occurrences", () => {
//#given
const content = "foo bar foo baz foo"
const oldText = "foo"
const newText = "qux"
//#when
const result = applyReplace(content, oldText, newText)
//#then
expect(result).toEqual("qux bar qux baz qux")
})
it("handles newline escapes in newText", () => {
//#given
const content = "hello world"
const oldText = "world"
const newText = "new\\nline"
//#when
const result = applyReplace(content, oldText, newText)
//#then
expect(result).toEqual("hello new\nline")
})
it("throws when oldText not found", () => {
//#given
const content = "hello world"
const oldText = "notfound"
const newText = "replacement"
//#when / #then
expect(() => applyReplace(content, oldText, newText)).toThrow("Text not found")
})
})
describe("applyHashlineEdits", () => {
it("applies single set_line edit", () => {
//#given
const content = "line 1\nline 2\nline 3"
const edits: SetLine[] = [{ type: "set_line", line: "2:b2", text: "new line 2" }]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("line 1\nnew line 2\nline 3")
})
it("applies multiple edits bottom-up (descending line order)", () => {
//#given
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
const edits: SetLine[] = [
{ type: "set_line", line: "2:b2", text: "new 2" },
{ type: "set_line", line: "4:5f", text: "new 4" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("line 1\nnew 2\nline 3\nnew 4\nline 5")
})
it("applies mixed edit types", () => {
//#given
const content = "line 1\nline 2\nline 3"
const edits: HashlineEdit[] = [
{ type: "insert_after", line: "1:02", text: "inserted" },
{ type: "set_line", line: "3:83", text: "modified" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("line 1\ninserted\nline 2\nmodified")
})
it("applies replace_lines edit", () => {
//#given
const content = "line 1\nline 2\nline 3\nline 4"
const edits: ReplaceLines[] = [
{ type: "replace_lines", start_line: "2:b2", end_line: "3:83", text: "replaced" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("line 1\nreplaced\nline 4")
})
it("applies replace fallback edit", () => {
//#given
const content = "hello world foo"
const edits: Replace[] = [{ type: "replace", old_text: "world", new_text: "universe" }]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("hello universe foo")
})
it("handles empty edits array", () => {
//#given
const content = "line 1\nline 2"
const edits: HashlineEdit[] = []
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("line 1\nline 2")
})
it("throws on hash mismatch with descriptive error", () => {
//#given
const content = "line 1\nline 2\nline 3"
const edits: SetLine[] = [{ type: "set_line", line: "2:ff", text: "new" }]
//#when / #then
expect(() => applyHashlineEdits(content, edits)).toThrow("Hash mismatch")
})
it("correctly handles index shifting with multiple edits", () => {
//#given
const content = "a\nb\nc\nd\ne"
const edits: InsertAfter[] = [
{ type: "insert_after", line: "2:bf", text: "x" },
{ type: "insert_after", line: "4:90", text: "y" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("a\nb\nx\nc\nd\ny\ne")
})
})
+123
View File
@@ -0,0 +1,123 @@
import { parseLineRef, validateLineRef } from "./validation"
import type { HashlineEdit } from "./types"
function unescapeNewlines(text: string): string {
return text.replace(/\\n/g, "\n")
}
export function applySetLine(lines: string[], anchor: string, newText: string): string[] {
validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
result[line - 1] = unescapeNewlines(newText)
return result
}
export function applyReplaceLines(
lines: string[],
startAnchor: string,
endAnchor: string,
newText: string
): string[] {
validateLineRef(lines, startAnchor)
validateLineRef(lines, endAnchor)
const { line: startLine } = parseLineRef(startAnchor)
const { line: endLine } = parseLineRef(endAnchor)
if (startLine > endLine) {
throw new Error(
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
)
}
const result = [...lines]
const newLines = unescapeNewlines(newText).split("\n")
result.splice(startLine - 1, endLine - startLine + 1, ...newLines)
return result
}
export function applyInsertAfter(lines: string[], anchor: string, text: string): string[] {
validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const newLines = unescapeNewlines(text).split("\n")
result.splice(line, 0, ...newLines)
return result
}
export function applyReplace(content: string, oldText: string, newText: string): string {
if (!content.includes(oldText)) {
throw new Error(`Text not found: "${oldText}"`)
}
return content.replaceAll(oldText, unescapeNewlines(newText))
}
function getEditLineNumber(edit: HashlineEdit): number {
switch (edit.type) {
case "set_line":
return parseLineRef(edit.line).line
case "replace_lines":
return parseLineRef(edit.end_line).line
case "insert_after":
return parseLineRef(edit.line).line
case "replace":
return Number.POSITIVE_INFINITY
default:
return Number.POSITIVE_INFINITY
}
}
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
if (edits.length === 0) {
return content
}
const sortedEdits = [...edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a))
let result = content
let lines = result.split("\n")
for (const edit of sortedEdits) {
switch (edit.type) {
case "set_line": {
validateLineRef(lines, edit.line)
const { line } = parseLineRef(edit.line)
lines[line - 1] = unescapeNewlines(edit.text)
break
}
case "replace_lines": {
validateLineRef(lines, edit.start_line)
validateLineRef(lines, edit.end_line)
const { line: startLine } = parseLineRef(edit.start_line)
const { line: endLine } = parseLineRef(edit.end_line)
if (startLine > endLine) {
throw new Error(
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
)
}
const newLines = unescapeNewlines(edit.text).split("\n")
lines.splice(startLine - 1, endLine - startLine + 1, ...newLines)
break
}
case "insert_after": {
validateLineRef(lines, edit.line)
const { line } = parseLineRef(edit.line)
const newLines = unescapeNewlines(edit.text).split("\n")
lines.splice(line, 0, ...newLines)
break
}
case "replace": {
result = lines.join("\n")
if (!result.includes(edit.old_text)) {
throw new Error(`Text not found: "${edit.old_text}"`)
}
result = result.replaceAll(edit.old_text, unescapeNewlines(edit.new_text))
lines = result.split("\n")
break
}
}
}
return lines.join("\n")
}
+8
View File
@@ -3,3 +3,11 @@ export { parseLineRef, validateLineRef } from "./validation"
export type { LineRef } from "./validation"
export type { SetLine, ReplaceLines, InsertAfter, Replace, HashlineEdit } from "./types"
export { HASH_DICT, HASHLINE_PATTERN } from "./constants"
export {
applyHashlineEdits,
applyInsertAfter,
applyReplace,
applyReplaceLines,
applySetLine,
} from "./edit-operations"
export { createHashlineEditTool } from "./tools"
+239
View File
@@ -0,0 +1,239 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { createHashlineEditTool } from "./tools"
import * as fs from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
import { computeLineHash } from "./hash-computation"
describe("createHashlineEditTool", () => {
let tempDir: string
let tool: ReturnType<typeof createHashlineEditTool>
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-edit-test-"))
tool = createHashlineEditTool()
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
describe("tool definition", () => {
it("has correct description", () => {
//#given tool is created
//#when accessing tool properties
//#then description explains LINE:HASH format
expect(tool.description).toContain("LINE:HASH")
expect(tool.description).toContain("set_line")
expect(tool.description).toContain("replace_lines")
expect(tool.description).toContain("insert_after")
expect(tool.description).toContain("replace")
})
it("has path parameter", () => {
//#given tool is created
//#when checking parameters
//#then path parameter exists as required string
expect(tool.args.path).toBeDefined()
})
it("has edits parameter as array", () => {
//#given tool is created
//#when checking parameters
//#then edits parameter exists as array
expect(tool.args.edits).toBeDefined()
})
})
describe("execute", () => {
it("returns error when file does not exist", async () => {
//#given non-existent file path
const nonExistentPath = path.join(tempDir, "non-existent.txt")
//#when executing tool
const result = await tool.execute(
{
path: nonExistentPath,
edits: [{ type: "set_line", line: "1:00", text: "new content" }],
},
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
)
//#then error is returned
expect(result).toContain("Error")
expect(result).toContain("not found")
})
it("applies set_line edit and returns diff", async () => {
//#given file with content
const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "line1\nline2\nline3")
const line2Hash = computeLineHash(2, "line2")
//#when executing set_line edit
const result = await tool.execute(
{
path: filePath,
edits: [{ type: "set_line", line: `2:${line2Hash}`, text: "modified line2" }],
},
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
)
//#then file is modified and diff is returned
const content = fs.readFileSync(filePath, "utf-8")
expect(content).toBe("line1\nmodified line2\nline3")
expect(result).toContain("modified line2")
})
it("applies insert_after edit", async () => {
//#given file with content
const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "line1\nline2")
const line1Hash = computeLineHash(1, "line1")
//#when executing insert_after edit
const result = await tool.execute(
{
path: filePath,
edits: [{ type: "insert_after", line: `1:${line1Hash}`, text: "inserted" }],
},
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
)
//#then line is inserted after specified line
const content = fs.readFileSync(filePath, "utf-8")
expect(content).toBe("line1\ninserted\nline2")
})
it("applies replace_lines edit", async () => {
//#given file with content
const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "line1\nline2\nline3\nline4")
const line2Hash = computeLineHash(2, "line2")
const line3Hash = computeLineHash(3, "line3")
//#when executing replace_lines edit
const result = await tool.execute(
{
path: filePath,
edits: [
{
type: "replace_lines",
start_line: `2:${line2Hash}`,
end_line: `3:${line3Hash}`,
text: "replaced",
},
],
},
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
)
//#then lines are replaced
const content = fs.readFileSync(filePath, "utf-8")
expect(content).toBe("line1\nreplaced\nline4")
})
it("applies replace edit", async () => {
//#given file with content
const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "hello world\nfoo bar")
//#when executing replace edit
const result = await tool.execute(
{
path: filePath,
edits: [{ type: "replace", old_text: "world", new_text: "universe" }],
},
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
)
//#then text is replaced
const content = fs.readFileSync(filePath, "utf-8")
expect(content).toBe("hello universe\nfoo bar")
})
it("applies multiple edits in bottom-up order", async () => {
//#given file with content
const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "line1\nline2\nline3")
const line1Hash = computeLineHash(1, "line1")
const line3Hash = computeLineHash(3, "line3")
//#when executing multiple edits
const result = await tool.execute(
{
path: filePath,
edits: [
{ type: "set_line", line: `1:${line1Hash}`, text: "new1" },
{ type: "set_line", line: `3:${line3Hash}`, text: "new3" },
],
},
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
)
//#then both edits are applied
const content = fs.readFileSync(filePath, "utf-8")
expect(content).toBe("new1\nline2\nnew3")
})
it("returns error on hash mismatch", async () => {
//#given file with content
const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "line1\nline2")
//#when executing with wrong hash (valid format but wrong value)
const result = await tool.execute(
{
path: filePath,
edits: [{ type: "set_line", line: "1:ff", text: "new" }],
},
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
)
//#then hash mismatch error is returned
expect(result).toContain("Error")
expect(result).toContain("hash")
})
it("handles escaped newlines in text", async () => {
//#given file with content
const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "line1\nline2")
const line1Hash = computeLineHash(1, "line1")
//#when executing with escaped newline
const result = await tool.execute(
{
path: filePath,
edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new\\nline" }],
},
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
)
//#then newline is unescaped
const content = fs.readFileSync(filePath, "utf-8")
expect(content).toBe("new\nline\nline2")
})
it("returns success result with diff summary", async () => {
//#given file with content
const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "old content")
const line1Hash = computeLineHash(1, "old content")
//#when executing edit
const result = await tool.execute(
{
path: filePath,
edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new content" }],
},
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
)
//#then result contains success indicator and diff
expect(result).toContain("Successfully")
expect(result).toContain("old content")
expect(result).toContain("new content")
})
})
})
+137
View File
@@ -0,0 +1,137 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import type { HashlineEdit } from "./types"
import { applyHashlineEdits } from "./edit-operations"
import { computeLineHash } from "./hash-computation"
interface HashlineEditArgs {
path: string
edits: HashlineEdit[]
}
function generateDiff(oldContent: string, newContent: string, filePath: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
let diff = `--- ${filePath}\n+++ ${filePath}\n`
const maxLines = Math.max(oldLines.length, newLines.length)
for (let i = 0; i < maxLines; i++) {
const oldLine = oldLines[i] ?? ""
const newLine = newLines[i] ?? ""
const lineNum = i + 1
const hash = computeLineHash(lineNum, newLine)
if (i >= oldLines.length) {
diff += `+ ${lineNum}:${hash}|${newLine}\n`
} else if (i >= newLines.length) {
diff += `- ${lineNum}: |${oldLine}\n`
} else if (oldLine !== newLine) {
diff += `- ${lineNum}: |${oldLine}\n`
diff += `+ ${lineNum}:${hash}|${newLine}\n`
}
}
return diff
}
export function createHashlineEditTool(): ToolDefinition {
return tool({
description: `Edit files using LINE:HASH format for precise, safe modifications.
LINE:HASH FORMAT:
Each line reference must be in "LINE:HASH" format where:
- LINE: 1-based line number
- HASH: First 2 characters of SHA-256 hash of line content (computed with computeLineHash)
- Example: "5:a3|const x = 1" means line 5 with hash "a3"
GETTING HASHES:
Use the read tool - it returns lines in "LINE:HASH|content" format.
FOUR OPERATION TYPES:
1. set_line: Replace a single line
{ "type": "set_line", "line": "5:a3", "text": "const y = 2" }
2. replace_lines: Replace a range of lines
{ "type": "replace_lines", "start_line": "5:a3", "end_line": "7:b2", "text": "new\ncontent" }
3. insert_after: Insert lines after a specific line
{ "type": "insert_after", "line": "5:a3", "text": "console.log('hi')" }
4. replace: Simple text replacement (no hash validation)
{ "type": "replace", "old_text": "foo", "new_text": "bar" }
HASH MISMATCH HANDLING:
If the hash doesn't match the current content, the edit fails with a hash mismatch error. This prevents editing stale content.
BOTTOM-UP APPLICATION:
Edits are applied from bottom to top (highest line numbers first) to preserve line number references.
ESCAPING:
Use \\n in text to represent literal newlines.`,
args: {
path: tool.schema.string().describe("Absolute path to the file to edit"),
edits: tool.schema
.array(
tool.schema.union([
tool.schema.object({
type: tool.schema.literal("set_line"),
line: tool.schema.string().describe("Line reference in LINE:HASH format"),
text: tool.schema.string().describe("New content for the line"),
}),
tool.schema.object({
type: tool.schema.literal("replace_lines"),
start_line: tool.schema.string().describe("Start line in LINE:HASH format"),
end_line: tool.schema.string().describe("End line in LINE:HASH format"),
text: tool.schema.string().describe("New content to replace the range"),
}),
tool.schema.object({
type: tool.schema.literal("insert_after"),
line: tool.schema.string().describe("Line reference in LINE:HASH format"),
text: tool.schema.string().describe("Content to insert after the line"),
}),
tool.schema.object({
type: tool.schema.literal("replace"),
old_text: tool.schema.string().describe("Text to find"),
new_text: tool.schema.string().describe("Replacement text"),
}),
])
)
.describe("Array of edit operations to apply"),
},
execute: async (args: HashlineEditArgs) => {
try {
const { path: filePath, edits } = args
if (!filePath) {
return "Error: path parameter is required"
}
if (!edits || !Array.isArray(edits) || edits.length === 0) {
return "Error: edits parameter must be a non-empty array"
}
const file = Bun.file(filePath)
const exists = await file.exists()
if (!exists) {
return `Error: File not found: ${filePath}`
}
const oldContent = await file.text()
const newContent = applyHashlineEdits(oldContent, edits)
await Bun.write(filePath, newContent)
const diff = generateDiff(oldContent, newContent, filePath)
return `Successfully applied ${edits.length} edit(s) to ${filePath}\n\n${diff}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.includes("hash")) {
return `Error: Hash mismatch - ${message}`
}
return `Error: ${message}`
}
},
})
}
+1
View File
@@ -43,6 +43,7 @@ export {
createTaskList,
createTaskUpdateTool,
} from "./task"
export { createHashlineEditTool } from "./hashline-edit"
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
const outputManager: BackgroundOutputManager = manager