diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts
index f9201be8a7db..040ad84ae0de 100644
--- a/packages/opencode/src/tool/apply_patch.ts
+++ b/packages/opencode/src/tool/apply_patch.ts
@@ -12,6 +12,7 @@ import { LSP } from "@/lsp/lsp"
import { FSUtil } from "@opencode-ai/core/fs-util"
import DESCRIPTION from "./apply_patch.txt"
import { FileSystem } from "@opencode-ai/core/filesystem"
+import { relative } from "./relative"
import { Format } from "../format"
import * as Bom from "@/util/bom"
@@ -193,7 +194,7 @@ export const ApplyPatchTool = Tool.define(
// Build per-file metadata for UI rendering (used for both permission and result)
const files = fileChanges.map((change) => ({
filePath: change.filePath,
- relativePath: path.relative(instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
+ relativePath: relative(instance, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type,
patch: change.diff,
additions: change.additions,
@@ -202,7 +203,7 @@ export const ApplyPatchTool = Tool.define(
}))
// Check permissions if needed
- const relativePaths = fileChanges.map((c) => path.relative(instance.worktree, c.filePath).replaceAll("\\", "/"))
+ const relativePaths = fileChanges.map((c) => relative(instance, c.filePath).replaceAll("\\", "/"))
yield* ctx.ask({
permission: "edit",
patterns: relativePaths,
@@ -273,13 +274,13 @@ export const ApplyPatchTool = Tool.define(
// Generate output summary
const summaryLines = fileChanges.map((change) => {
if (change.type === "add") {
- return `A ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}`
+ return `A ${relative(instance, change.filePath).replaceAll("\\", "/")}`
}
if (change.type === "delete") {
- return `D ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}`
+ return `D ${relative(instance, change.filePath).replaceAll("\\", "/")}`
}
const target = change.movePath ?? change.filePath
- return `M ${path.relative(instance.worktree, target).replaceAll("\\", "/")}`
+ return `M ${relative(instance, target).replaceAll("\\", "/")}`
})
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
@@ -288,7 +289,7 @@ export const ApplyPatchTool = Tool.define(
const target = change.movePath ?? change.filePath
const block = LSP.Diagnostic.report(target, diagnostics[FSUtil.normalizePath(target)] ?? [])
if (!block) continue
- const rel = path.relative(instance.worktree, target).replaceAll("\\", "/")
+ const rel = relative(instance, target).replaceAll("\\", "/")
output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`
}
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index a92e4720c0fc..35d695c67bec 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -17,6 +17,7 @@ import { InstanceState } from "@/effect/instance-state"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory"
import { FSUtil } from "@opencode-ai/core/fs-util"
+import { relative } from "./relative"
import * as Bom from "@/util/bom"
function normalizeLineEndings(text: string): string {
@@ -81,6 +82,7 @@ export const EditTool = Tool.define(
? params.filePath
: path.join(instance.directory, params.filePath)
yield* assertExternalDirectoryEffect(ctx, filePath)
+ const rel = relative(instance, filePath)
let diff = ""
let contentOld = ""
@@ -101,7 +103,7 @@ export const EditTool = Tool.define(
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
- patterns: [path.relative(instance.worktree, filePath)],
+ patterns: [rel],
always: ["*"],
metadata: {
filepath: filePath,
@@ -144,7 +146,7 @@ export const EditTool = Tool.define(
)
yield* ctx.ask({
permission: "edit",
- patterns: [path.relative(instance.worktree, filePath)],
+ patterns: [rel],
always: ["*"],
metadata: {
filepath: filePath,
@@ -206,7 +208,7 @@ export const EditTool = Tool.define(
diff,
filediff,
},
- title: `${path.relative(instance.worktree, filePath)}`,
+ title: rel,
output,
}
}),
diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts
index a605cea749d5..a9b23e084bfb 100644
--- a/packages/opencode/src/tool/lsp.ts
+++ b/packages/opencode/src/tool/lsp.ts
@@ -7,6 +7,7 @@ import { InstanceState } from "@/effect/instance-state"
import { pathToFileURL } from "url"
import { assertExternalDirectoryEffect } from "./external-directory"
import { FSUtil } from "@opencode-ai/core/fs-util"
+import { relative } from "./relative"
const operations = [
"goToDefinition",
@@ -62,7 +63,7 @@ export const LspTool = Tool.define(
const uri = pathToFileURL(file).href
const position = { file, line: args.line - 1, character: args.character - 1 }
- const relPath = path.relative(instance.worktree, file)
+ const relPath = relative(instance, file)
const detail =
args.operation === "workspaceSymbol"
? ""
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index 678ed4451048..e7e55ded38cd 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt"
import { InstanceState } from "@/effect/instance-state"
import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
+import { relative } from "./relative"
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
const DEFAULT_READ_LIMIT = 2000
@@ -238,7 +239,7 @@ export const ReadTool = Tool.define<
if (process.platform === "win32") {
filepath = FSUtil.normalizePath(filepath)
}
- const title = path.relative(instance.worktree, filepath)
+ const rel = relative(instance, filepath)
const stat = yield* fs.stat(filepath).pipe(
Effect.catchIf(
@@ -254,7 +255,7 @@ export const ReadTool = Tool.define<
yield* ctx.ask({
permission: "read",
- patterns: [path.relative(instance.worktree, filepath)],
+ patterns: [rel],
always: ["*"],
metadata: {},
})
@@ -270,7 +271,7 @@ export const ReadTool = Tool.define<
const truncated = start + sliced.length < items.length
return {
- title,
+ title: rel,
output: [
`${filepath}`,
`directory`,
@@ -307,7 +308,7 @@ export const ReadTool = Tool.define<
const bytes = yield* fs.readFile(filepath)
const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully"
return {
- title,
+ title: rel,
output: msg,
metadata: {
preview: msg,
@@ -357,7 +358,7 @@ export const ReadTool = Tool.define<
}
return {
- title,
+ title: rel,
output,
metadata: {
preview: file.raw.slice(0, 20).join("\n"),
diff --git a/packages/opencode/src/tool/relative.ts b/packages/opencode/src/tool/relative.ts
new file mode 100644
index 000000000000..df484b52f997
--- /dev/null
+++ b/packages/opencode/src/tool/relative.ts
@@ -0,0 +1,8 @@
+import path from "path"
+import type { InstanceContext } from "@/project/instance-context"
+
+export function relative(instance: InstanceContext, file: string) {
+
+ const root = instance.worktree === "/" ? instance.directory : instance.worktree
+ return path.relative(root, file)
+}
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index 37be6d8c47bc..0dccad816505 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -13,6 +13,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceState } from "@/effect/instance-state"
import { trimDiff } from "./edit"
import { assertExternalDirectoryEffect } from "./external-directory"
+import { relative } from "./relative"
import * as Bom from "@/util/bom"
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
@@ -49,11 +50,13 @@ export const WriteTool = Tool.define(
const desiredBom = source.bom || next.bom
const contentOld = source.text
const contentNew = next.text
+
+ const rel = relative(instance, filepath)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
- patterns: [path.relative(instance.worktree, filepath)],
+ patterns: [rel],
always: ["*"],
metadata: {
filepath,
@@ -90,7 +93,7 @@ export const WriteTool = Tool.define(
}
return {
- title: path.relative(instance.worktree, filepath),
+ title: rel,
metadata: {
diagnostics,
filepath,
diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts
index 01a09add8732..ec8f8109c11b 100644
--- a/packages/opencode/test/tool/apply_patch.test.ts
+++ b/packages/opencode/test/tool/apply_patch.test.ts
@@ -188,6 +188,20 @@ describe("tool.apply_patch freeform", () => {
{ git: true },
)
+ it.instance("uses directory-relative permission paths in non-git projects", () =>
+ Effect.gen(function* () {
+ const { ctx, calls } = makeCtx()
+
+ const patchText = "*** Begin Patch\n*** Add File: .agents/new.txt\n+created\n*** End Patch"
+
+ yield* execute({ patchText }, ctx)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.patterns).toEqual([".agents/new.txt"])
+ expect(calls[0]?.metadata.files[0]?.relativePath).toBe(".agents/new.txt")
+ }),
+ )
+
it.instance("applies multiple hunks to one file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts
index 12db53551856..23bf63d05176 100644
--- a/packages/opencode/test/tool/edit.test.ts
+++ b/packages/opencode/test/tool/edit.test.ts
@@ -143,6 +143,28 @@ describe("tool.edit", () => {
yield* Deferred.await(updated)
}),
)
+
+ it.instance("uses directory-relative permission paths in non-git projects", () =>
+ Effect.gen(function* () {
+ const test = yield* TestInstance
+ const filepath = path.join(test.directory, ".agents", "file.txt")
+ const calls: Array<{ patterns: readonly string[] }> = []
+
+ yield* run(
+ { filePath: filepath, oldString: "", newString: "new content" },
+ {
+ ...ctx,
+ ask: (input) =>
+ Effect.sync(() => {
+ calls.push({ patterns: input.patterns })
+ }),
+ },
+ )
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.patterns).toEqual([path.join(".agents", "file.txt")])
+ }),
+ )
})
describe("editing existing files", () => {
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index 67205f56e384..88b4cac13ab3 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -146,6 +146,21 @@ const asks = () => {
}
describe("tool.read external_directory permission", () => {
+ it.live("uses directory-relative read permission paths in non-git projects", () =>
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped()
+ yield* put(path.join(dir, ".agents", "test.txt"), "hello world")
+
+ const { items, next } = asks()
+
+ const result = yield* exec(dir, { filePath: path.join(dir, ".agents", "test.txt") }, next)
+ expect(result.output).toContain("hello world")
+ const read = items.find((item) => item.permission === "read")
+ expect(read).toBeDefined()
+ expect(read!.patterns).toEqual([path.join(".agents", "test.txt")])
+ }),
+ )
+
it.live("allows reading absolute path inside project directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts
index 63a2a52aa986..bbe50073ad8d 100644
--- a/packages/opencode/test/tool/write.test.ts
+++ b/packages/opencode/test/tool/write.test.ts
@@ -91,6 +91,31 @@ describe("tool.write", () => {
expect(content).toBe("relative content")
}),
)
+
+ it.instance("uses directory-relative permission paths in non-git projects", () =>
+ Effect.gen(function* () {
+ const test = yield* TestInstance
+ const filepath = path.join(test.directory, ".agents", "file.txt")
+ const calls: Array<{ patterns: readonly string[] }> = []
+
+ yield* run(
+ {
+ filePath: filepath,
+ content: "content",
+ },
+ {
+ ...ctx,
+ ask: (input) =>
+ Effect.sync(() => {
+ calls.push({ patterns: input.patterns })
+ }),
+ },
+ )
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.patterns).toEqual([path.join(".agents", "file.txt")])
+ }),
+ )
})
describe("existing file overwrite", () => {