From 3f498b9f4c3326326743283b2c57f3172e469bb6 Mon Sep 17 00:00:00 2001
From: ops
Date: Fri, 20 Feb 2026 18:32:39 +0100
Subject: [PATCH 1/7] fix: use path normalization to fix win32 issues
---
packages/app/src/custom-elements.d.ts | 19 ++++++-
packages/opencode/src/agent/agent.ts | 7 +--
.../src/cli/cmd/tui/routes/session/index.tsx | 10 ++--
.../cli/cmd/tui/routes/session/permission.tsx | 7 +--
packages/opencode/src/config/config.ts | 7 ++-
packages/opencode/src/file/ignore.ts | 10 ++--
packages/opencode/src/kilocode/paths.ts | 6 +--
packages/opencode/src/lsp/client.ts | 4 +-
packages/opencode/src/lsp/index.ts | 4 +-
packages/opencode/src/patch/index.ts | 8 +--
packages/opencode/src/project/instance.ts | 11 ++--
packages/opencode/src/project/project.ts | 10 ++--
packages/opencode/src/pty/index.ts | 9 +++-
.../opencode/src/server/routes/session.ts | 6 ++-
packages/opencode/src/session/index.ts | 8 +--
packages/opencode/src/session/prompt.ts | 3 +-
packages/opencode/src/snapshot/index.ts | 5 +-
packages/opencode/src/tool/apply_patch.ts | 14 +++---
packages/opencode/src/tool/bash.ts | 33 +++++-------
packages/opencode/src/tool/edit.ts | 11 ++--
.../opencode/src/tool/external-directory.ts | 6 ++-
packages/opencode/src/tool/glob.ts | 9 ++--
packages/opencode/src/tool/grep.ts | 5 +-
packages/opencode/src/tool/ls.ts | 9 ++--
packages/opencode/src/tool/lsp.ts | 5 +-
packages/opencode/src/tool/multiedit.ts | 3 +-
packages/opencode/src/tool/plan.ts | 6 +--
packages/opencode/src/tool/read.ts | 11 ++--
packages/opencode/src/tool/truncation.ts | 10 ++--
packages/opencode/src/tool/write.ts | 9 ++--
packages/opencode/src/util/filesystem.ts | 50 ++++++++++++++-----
packages/opencode/test/config/config.test.ts | 6 ++-
packages/opencode/test/fixture/fixture.ts | 15 ++++--
packages/opencode/test/ide/ide.test.ts | 3 +-
.../opencode/test/project/project.test.ts | 17 ++++---
packages/opencode/test/session/llm.test.ts | 41 ++++++++++++---
packages/opencode/test/skill/skill.test.ts | 11 ++--
.../opencode/test/snapshot/snapshot.test.ts | 6 ++-
packages/opencode/test/tool/bash.test.ts | 12 +++--
.../test/tool/external-directory.test.ts | 5 +-
packages/opencode/test/tool/read.test.ts | 3 +-
.../opencode/test/util/filesystem.test.ts | 32 ++++++++++++
packages/ui/src/components/message-part.tsx | 6 ++-
packages/util/src/path.ts | 8 +++
44 files changed, 320 insertions(+), 160 deletions(-)
mode change 120000 => 100644 packages/app/src/custom-elements.d.ts
diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts
deleted file mode 120000
index e4ea0d6ceb..0000000000
--- a/packages/app/src/custom-elements.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-../../ui/src/custom-elements.d.ts
\ No newline at end of file
diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts
new file mode 100644
index 0000000000..72f87bc24d
--- /dev/null
+++ b/packages/app/src/custom-elements.d.ts
@@ -0,0 +1,18 @@
+// kilocode_change - copy content instead of symlink for Windows compatibility
+import { DIFFS_TAG_NAME } from "@pierre/diffs"
+
+/**
+ * TypeScript declaration for the custom element.
+ * This tells TypeScript that is a valid JSX element in SolidJS.
+ * Required for using the @pierre/diffs web component in .tsx files.
+ */
+
+declare module "solid-js" {
+ namespace JSX {
+ interface IntrinsicElements {
+ [DIFFS_TAG_NAME]: HTMLAttributes
+ }
+ }
+}
+
+export {}
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index 647c562e70..d64f3d901f 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -24,6 +24,7 @@ import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
+import { Filesystem } from "@/util/filesystem"
export namespace Agent {
export const Info = z
@@ -106,12 +107,12 @@ export namespace Agent {
question: "allow",
plan_exit: "allow",
external_directory: {
- [path.join(Global.Path.data, "plans", "*")]: "allow",
+ [Filesystem.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
- [path.join(".opencode", "plans", "*.md")]: "allow",
- [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
+ [Filesystem.join(".opencode", "plans", "*.md")]: "allow",
+ [Filesystem.relative(Instance.worktree, Filesystem.join(Global.Path.data, "plans", "*.md"))]: "allow",
},
}),
user,
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 54f152db7e..c7d5ef15b9 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -857,7 +857,7 @@ export function Session() {
} else {
const exportDir = process.cwd()
const filename = options.filename.trim()
- const filepath = path.join(exportDir, filename)
+ const filepath = Filesystem.join(exportDir, filename)
await Bun.write(filepath, transcript)
@@ -1675,7 +1675,7 @@ function Bash(props: ToolProps) {
const base = sync.data.path.directory
if (!base) return undefined
- const absolute = path.resolve(base, workdir)
+ const absolute = Filesystem.resolve(base, workdir)
if (absolute === base) return undefined
const home = Global.Path.home
@@ -1730,7 +1730,7 @@ function Write(props: ToolProps) {
})
const diagnostics = createMemo(() => {
- const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
+ const filePath = Filesystem.realpath(props.input.filePath ?? "")
return props.metadata.diagnostics?.[filePath] ?? []
})
@@ -1940,7 +1940,7 @@ function Edit(props: ToolProps) {
const diffContent = createMemo(() => props.metadata.diff)
const diagnostics = createMemo(() => {
- const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
+ const filePath = Filesystem.realpath(props.input.filePath ?? "")
const arr = props.metadata.diagnostics?.[filePath] ?? []
return arr.filter((x) => x.severity === 1).slice(0, 3)
})
@@ -2133,7 +2133,7 @@ function Skill(props: ToolProps) {
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
- return path.relative(process.cwd(), input) || "."
+ return Filesystem.relative(process.cwd(), input) || "."
}
return input
}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
index aff48d2fce..27ef28409e 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
@@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
+import { Filesystem } from "@/util/filesystem"
type PermissionStage = "permission" | "always" | "reject"
@@ -23,8 +24,8 @@ function normalizePath(input?: string) {
const cwd = process.cwd()
const home = Global.Path.home
- const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
- const relative = path.relative(cwd, absolute)
+ const absolute = path.isAbsolute(input) ? input : Filesystem.resolve(cwd, input)
+ const relative = Filesystem.relative(cwd, absolute)
if (!relative) return "."
if (!relative.startsWith("..")) return relative
@@ -248,7 +249,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const derived =
typeof pattern === "string"
? pattern.includes("*")
- ? path.dirname(pattern)
+ ? Filesystem.dirname(pattern)
: pattern
: undefined
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 063fa5b780..d720fb5e56 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -411,10 +411,13 @@ export namespace Config {
}
function rel(item: string, patterns: string[]) {
+ // Normalize the item path first
+ const normalizedItem = Filesystem.normalize(item)
for (const pattern of patterns) {
- const index = item.indexOf(pattern)
+ const normalizedPattern = Filesystem.normalize(pattern)
+ const index = normalizedItem.indexOf(normalizedPattern)
if (index === -1) continue
- return item.slice(index + pattern.length)
+ return normalizedItem.slice(index + normalizedPattern.length)
}
}
diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts
index 7230f67afe..b8d329c6d0 100644
--- a/packages/opencode/src/file/ignore.ts
+++ b/packages/opencode/src/file/ignore.ts
@@ -1,4 +1,4 @@
-import { sep } from "node:path"
+import { Filesystem } from "../util/filesystem"
export namespace FileIgnore {
const FOLDERS = new Set([
@@ -64,18 +64,20 @@ export namespace FileIgnore {
whitelist?: Bun.Glob[]
},
) {
+ const normalizedPath = Filesystem.normalize(filepath)
+
for (const glob of opts?.whitelist || []) {
- if (glob.match(filepath)) return false
+ if (glob.match(normalizedPath)) return false
}
- const parts = filepath.split(sep)
+ const parts = normalizedPath.split("/")
for (let i = 0; i < parts.length; i++) {
if (FOLDERS.has(parts[i])) return true
}
const extra = opts?.extra || []
for (const glob of [...FILE_GLOBS, ...extra]) {
- if (glob.match(filepath)) return true
+ if (glob.match(normalizedPath)) return true
}
return false
diff --git a/packages/opencode/src/kilocode/paths.ts b/packages/opencode/src/kilocode/paths.ts
index e784f517bf..0b7373fcc5 100644
--- a/packages/opencode/src/kilocode/paths.ts
+++ b/packages/opencode/src/kilocode/paths.ts
@@ -62,7 +62,7 @@ export namespace KilocodePaths {
}),
)
for (const dir of projectDirs) {
- const skillsDir = path.join(dir, "skills")
+ const skillsDir = Filesystem.join(dir, "skills")
if (await Filesystem.isDir(skillsDir)) {
directories.push(dir) // Return parent (.kilocode/), not skills/
}
@@ -71,14 +71,14 @@ export namespace KilocodePaths {
if (!opts.skipGlobalPaths) {
// 2. Global ~/.kilocode/
const global = globalDir()
- const globalSkills = path.join(global, "skills")
+ const globalSkills = Filesystem.join(global, "skills")
if (await Filesystem.isDir(globalSkills)) {
directories.push(global) // Return parent, not skills/
}
// 3. VSCode extension global storage (marketplace-installed skills)
const vscode = vscodeGlobalStorage()
- const vscodeSkills = path.join(vscode, "skills")
+ const vscodeSkills = Filesystem.join(vscode, "skills")
if (await Filesystem.isDir(vscodeSkills)) {
directories.push(vscode) // Return parent, not skills/
}
diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts
index 8704b65acb..d770bc793a 100644
--- a/packages/opencode/src/lsp/client.ts
+++ b/packages/opencode/src/lsp/client.ts
@@ -50,7 +50,7 @@ export namespace LSPClient {
const diagnostics = new Map()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
- const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
+ const filePath = Filesystem.realpath(fileURLToPath(params.uri))
l.info("textDocument/publishDiagnostics", {
path: filePath,
count: params.diagnostics.length,
@@ -208,7 +208,7 @@ export namespace LSPClient {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
- const normalizedPath = Filesystem.normalizePath(
+ const normalizedPath = Filesystem.realpath(
path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
)
log.info("waiting for diagnostics", { path: normalizedPath })
diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts
index dc1f5dee05..f63e9dafd1 100644
--- a/packages/opencode/src/lsp/index.ts
+++ b/packages/opencode/src/lsp/index.ts
@@ -10,6 +10,7 @@ import { Config } from "../config/config"
import { spawn } from "child_process"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
+import { Filesystem } from "@/util/filesystem"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -111,9 +112,10 @@ export namespace LSP {
root: existing?.root ?? (async () => Instance.directory),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => {
+ const normalizedRoot = Filesystem.normalize(root)
return {
process: spawn(item.command[0], item.command.slice(1), {
- cwd: root,
+ cwd: normalizedRoot,
env: {
...process.env,
...item.env,
diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts
index 0efeff544f..278080966c 100644
--- a/packages/opencode/src/patch/index.ts
+++ b/packages/opencode/src/patch/index.ts
@@ -79,23 +79,23 @@ export namespace Patch {
const line = lines[startIdx]
if (line.startsWith("*** Add File:")) {
- const filePath = line.split(":", 2)[1]?.trim()
+ const filePath = line.substring("*** Add File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Delete File:")) {
- const filePath = line.split(":", 2)[1]?.trim()
+ const filePath = line.substring("*** Delete File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Update File:")) {
- const filePath = line.split(":", 2)[1]?.trim()
+ const filePath = line.substring("*** Update File:".length).trim()
let movePath: string | undefined
let nextIdx = startIdx + 1
// Check for move directive
if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
- movePath = lines[nextIdx].split(":", 2)[1]?.trim()
+ movePath = lines[nextIdx].substring("*** Move to:".length).trim()
nextIdx++
}
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index 98031f18d3..2f296d5fab 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/packages/opencode/src/project/instance.ts
@@ -20,13 +20,14 @@ const disposal = {
export const Instance = {
async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise {
- let existing = cache.get(input.directory)
+ const directory = Filesystem.normalize(input.directory)
+ let existing = cache.get(directory)
if (!existing) {
- Log.Default.info("creating instance", { directory: input.directory })
+ Log.Default.info("creating instance", { directory })
existing = iife(async () => {
- const { project, sandbox } = await Project.fromDirectory(input.directory)
+ const { project, sandbox } = await Project.fromDirectory(directory)
const ctx = {
- directory: input.directory,
+ directory: directory,
worktree: sandbox,
project,
}
@@ -35,7 +36,7 @@ export const Instance = {
})
return ctx
})
- cache.set(input.directory, existing)
+ cache.set(directory, existing)
}
const ctx = await existing
return context.provide(ctx, async () => {
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index f636882d8c..a3a65c1a1b 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -58,12 +58,12 @@ export namespace Project {
const dotgit = await matches.next().then((x) => x.value)
await matches.return()
if (dotgit) {
- let sandbox = path.dirname(dotgit)
+ let sandbox = Filesystem.dirname(dotgit)
const gitBinary = Bun.which("git")
// cached id calculation
- let id = await Bun.file(path.join(dotgit, "opencode"))
+ let id = await Bun.file(Filesystem.join(dotgit, "opencode"))
.text()
.then((x) => x.trim())
.catch(() => undefined)
@@ -102,7 +102,7 @@ export namespace Project {
id = roots[0]
if (id) {
- void Bun.file(path.join(dotgit, "opencode"))
+ void Bun.file(Filesystem.join(dotgit, "opencode"))
.write(id)
.catch(() => undefined)
}
@@ -120,7 +120,7 @@ export namespace Project {
const top = await git(["rev-parse", "--show-toplevel"], {
cwd: sandbox,
})
- .then(async (result) => path.resolve(sandbox, (await result.text()).trim()))
+ .then(async (result) => Filesystem.resolve(sandbox, (await result.text()).trim()))
.catch(() => undefined)
if (!top) {
@@ -138,7 +138,7 @@ export namespace Project {
cwd: sandbox,
})
.then(async (result) => {
- const dirname = path.dirname((await result.text()).trim())
+ const dirname = Filesystem.dirname((await result.text()).trim())
if (dirname === ".") return sandbox
return dirname
})
diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts
index d03b6f0994..8cea7c60e3 100644
--- a/packages/opencode/src/pty/index.ts
+++ b/packages/opencode/src/pty/index.ts
@@ -7,6 +7,7 @@ import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
+import { Filesystem } from "@/util/filesystem"
import { Plugin } from "@/plugin"
export namespace Pty {
@@ -134,7 +135,13 @@ export namespace Pty {
args.push("-l")
}
- const cwd = input.cwd || Instance.directory
+ const cwd = Filesystem.normalize(input.cwd || Instance.directory)
+
+ // Validate directory exists
+ if (!(await Filesystem.isDir(cwd))) {
+ throw new Error(`Directory does not exist: ${cwd}`)
+ }
+
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const env = {
...process.env,
diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts
index 82e6f3121b..12e8f0508b 100644
--- a/packages/opencode/src/server/routes/session.ts
+++ b/packages/opencode/src/server/routes/session.ts
@@ -16,6 +16,7 @@ import { Log } from "../../util/log"
import { PermissionNext } from "@/permission/next"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
+import { Filesystem } from "@/util/filesystem"
const log = Log.create({ service: "server" })
@@ -54,9 +55,12 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const query = c.req.valid("query")
const term = query.search?.toLowerCase()
+ // Normalize directory path for comparison
+ const normalizedQueryDir = query.directory ? Filesystem.normalize(query.directory) : undefined
const sessions: Session.Info[] = []
for await (const session of Session.list()) {
- if (query.directory !== undefined && session.directory !== query.directory) continue
+ if (normalizedQueryDir !== undefined && Filesystem.normalize(session.directory) !== normalizedQueryDir)
+ continue
if (query.roots && session.parentID) continue
if (query.start !== undefined && session.time.updated < query.start) continue
if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index a5c1de2801..f9743a6d84 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -1,5 +1,4 @@
import { Slug } from "@opencode-ai/util/slug"
-import path from "path"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
@@ -24,6 +23,7 @@ import { PermissionNext } from "@/permission/next"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife"
+import { Filesystem } from "@/util/filesystem"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -268,9 +268,9 @@ export namespace Session {
export function plan(input: { slug: string; time: { created: number } }) {
const base = Instance.project.vcs
- ? path.join(Instance.worktree, ".opencode", "plans")
- : path.join(Global.Path.data, "plans")
- return path.join(base, [input.time.created, input.slug].join("-") + ".md")
+ ? Filesystem.join(Instance.worktree, ".opencode", "plans")
+ : Filesystem.join(Global.Path.data, "plans")
+ return Filesystem.join(base, [input.time.created, input.slug].join("-") + ".md")
}
export const get = fn(Identifier.schema("session"), async (id) => {
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index ae6248a0ef..5f7418201f 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -45,6 +45,7 @@ import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
+import { Filesystem } from "@/util/filesystem"
import { PlanFollowup } from "@/kilocode/plan-followup" // kilocode_change
// @ts-ignore
@@ -1106,7 +1107,7 @@ export namespace SessionPrompt {
log.info("file", { mime: part.mime })
// have to normalize, symbol search returns absolute paths
// Decode the pathname since URL constructor doesn't automatically decode it
- const filepath = fileURLToPath(part.url)
+ const filepath = Filesystem.normalize(fileURLToPath(part.url))
const stat = await Bun.file(filepath)
.stat()
.catch(() => undefined)
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index 8a214ddcac..68f0cdbbf4 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -8,6 +8,7 @@ import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
+import { Filesystem } from "../util/filesystem"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
@@ -105,7 +106,7 @@ export namespace Snapshot {
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
- .map((x) => path.join(Instance.worktree, x)),
+ .map((x) => Filesystem.join(Instance.worktree, x)),
}
}
@@ -251,6 +252,6 @@ export namespace Snapshot {
function gitdir() {
const project = Instance.project
- return path.join(Global.Path.data, "snapshot", project.id)
+ return Filesystem.join(Global.Path.data, "snapshot", project.id)
}
}
diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts
index 1344467c71..ffe7c400b4 100644
--- a/packages/opencode/src/tool/apply_patch.ts
+++ b/packages/opencode/src/tool/apply_patch.ts
@@ -161,7 +161,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
// 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),
+ relativePath: Filesystem.relative(Instance.worktree, change.movePath ?? change.filePath),
type: change.type,
diff: change.diff,
before: change.oldContent,
@@ -172,7 +172,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
}))
// Check permissions if needed
- const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath))
+ const relativePaths = fileChanges.map((c) => Filesystem.relative(Instance.worktree, c.filePath))
await ctx.ask({
permission: "edit",
patterns: relativePaths,
@@ -242,13 +242,13 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
// Generate output summary
const summaryLines = fileChanges.map((change) => {
if (change.type === "add") {
- return `A ${path.relative(Instance.worktree, change.filePath)}`
+ return `A ${Filesystem.relative(Instance.worktree, change.filePath)}`
}
if (change.type === "delete") {
- return `D ${path.relative(Instance.worktree, change.filePath)}`
+ return `D ${Filesystem.relative(Instance.worktree, change.filePath)}`
}
const target = change.movePath ?? change.filePath
- return `M ${path.relative(Instance.worktree, target)}`
+ return `M ${Filesystem.relative(Instance.worktree, target)}`
})
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
@@ -257,14 +257,14 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
- const normalized = Filesystem.normalizePath(target)
+ const normalized = Filesystem.realpath(target)
const issues = diagnostics[normalized] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
- output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n`
+ output += `\n\nLSP errors detected in ${Filesystem.relative(Instance.worktree, target)}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n`
}
}
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index db8e53c19a..29aa85a18f 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -77,16 +77,21 @@ export const BashTool = Tool.define("bash", async () => {
}),
async execute(params, ctx) {
const cwd = params.workdir || Instance.directory
+ const normalizedCwd = Filesystem.normalize(cwd)
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
+ // Validate working directory exists to prevent Bun spawn crashes on Windows
+ if (!(await Filesystem.isDir(normalizedCwd))) {
+ throw new Error(`Working directory does not exist or is not a directory: ${cwd}`)
+ }
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const tree = await parser().then((p) => p.parse(params.command))
if (!tree) {
throw new Error("Failed to parse command")
}
const directories = new Set()
- if (!Instance.containsPath(cwd)) directories.add(cwd)
+ if (!Instance.containsPath(normalizedCwd)) directories.add(normalizedCwd)
const patterns = new Set()
const always = new Set()
@@ -116,24 +121,10 @@ export const BashTool = Tool.define("bash", async () => {
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
- const resolved = await $`realpath ${arg}`
- .cwd(cwd)
- .quiet()
- .nothrow()
- .text()
- .then((x) => x.trim())
- log.info("resolved path", { arg, resolved })
- if (resolved) {
- // Git Bash on Windows returns Unix-style paths like /c/Users/...
- const normalized =
- process.platform === "win32" && resolved.match(/^\/[a-z]\//)
- ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
- : resolved
- if (!Instance.containsPath(normalized)) {
- const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized)
- directories.add(dir)
- }
- }
+ const target = Filesystem.resolve(normalizedCwd, arg)
+ const dir = (await Filesystem.isDir(target)) ? target : Filesystem.dirname(target)
+ log.info("resolved path", { arg, dir, target })
+ if (!Filesystem.contains(Instance.directory, dir)) directories.add(dir)
}
}
@@ -145,7 +136,7 @@ export const BashTool = Tool.define("bash", async () => {
}
if (directories.size > 0) {
- const globs = Array.from(directories).map((dir) => path.join(dir, "*"))
+ const globs = Array.from(directories).map((dir) => Filesystem.normalize(path.join(dir, "*")))
await ctx.ask({
permission: "external_directory",
patterns: globs,
@@ -166,7 +157,7 @@ export const BashTool = Tool.define("bash", async () => {
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const proc = spawn(params.command, {
shell,
- cwd,
+ cwd: normalizedCwd,
env: {
...process.env,
...shellEnv.env,
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index d84f6ec349..abbe5ef3cc 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -41,7 +41,8 @@ export const EditTool = Tool.define("edit", {
throw new Error("No changes to apply: oldString and newString are identical.")
}
- const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
+ const normalized = Filesystem.normalize(params.filePath)
+ const filePath = path.isAbsolute(normalized) ? normalized : Filesystem.join(Instance.directory, normalized)
await assertExternalDirectory(ctx, filePath)
let diff = ""
@@ -54,7 +55,7 @@ export const EditTool = Tool.define("edit", {
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
- patterns: [path.relative(Instance.worktree, filePath)],
+ patterns: [Filesystem.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
@@ -86,7 +87,7 @@ export const EditTool = Tool.define("edit", {
)
await ctx.ask({
permission: "edit",
- patterns: [path.relative(Instance.worktree, filePath)],
+ patterns: [Filesystem.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
@@ -132,7 +133,7 @@ export const EditTool = Tool.define("edit", {
let output = "Edit applied successfully."
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
- const normalizedFilePath = Filesystem.normalizePath(filePath)
+ const normalizedFilePath = Filesystem.realpath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
@@ -148,7 +149,7 @@ export const EditTool = Tool.define("edit", {
diff,
filediff,
},
- title: `${path.relative(Instance.worktree, filePath)}`,
+ title: `${Filesystem.relative(Instance.worktree, filePath)}`,
output,
}
},
diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts
index 1d3958fc46..cc261d3d23 100644
--- a/packages/opencode/src/tool/external-directory.ts
+++ b/packages/opencode/src/tool/external-directory.ts
@@ -1,6 +1,7 @@
import path from "path"
import type { Tool } from "./tool"
import { Instance } from "../project/instance"
+import { Filesystem } from "@/util/filesystem"
type Kind = "file" | "directory"
@@ -14,11 +15,12 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
if (options?.bypass) return
+ target = Filesystem.normalize(target)
if (Instance.containsPath(target)) return
const kind = options?.kind ?? "file"
- const parentDir = kind === "directory" ? target : path.dirname(target)
- const glob = path.join(parentDir, "*")
+ const parentDir = kind === "directory" ? target : Filesystem.dirname(target)
+ const glob = Filesystem.join(parentDir, "*")
await ctx.ask({
permission: "external_directory",
diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts
index 9df1eedca4..312bbd83f3 100644
--- a/packages/opencode/src/tool/glob.ts
+++ b/packages/opencode/src/tool/glob.ts
@@ -5,6 +5,7 @@ import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
+import { Filesystem } from "../util/filesystem"
export const GlobTool = Tool.define("glob", {
description: DESCRIPTION,
@@ -28,8 +29,8 @@ export const GlobTool = Tool.define("glob", {
},
})
- let search = params.path ?? Instance.directory
- search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
+ let search = params.path ? Filesystem.normalize(params.path) : Instance.directory
+ search = path.isAbsolute(search) ? search : Filesystem.resolve(Instance.directory, search)
await assertExternalDirectory(ctx, search, { kind: "directory" })
const limit = 100
@@ -44,7 +45,7 @@ export const GlobTool = Tool.define("glob", {
truncated = true
break
}
- const full = path.resolve(search, file)
+ const full = Filesystem.resolve(search, file)
const stats = await Bun.file(full)
.stat()
.then((x) => x.mtime.getTime())
@@ -69,7 +70,7 @@ export const GlobTool = Tool.define("glob", {
}
return {
- title: path.relative(Instance.worktree, search),
+ title: Filesystem.relative(Instance.worktree, search),
metadata: {
count: files.length,
truncated,
diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts
index 41ed494de9..00bd2e5af8 100644
--- a/packages/opencode/src/tool/grep.ts
+++ b/packages/opencode/src/tool/grep.ts
@@ -6,6 +6,7 @@ import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectory } from "./external-directory"
+import { Filesystem } from "../util/filesystem"
const MAX_LINE_LENGTH = 2000
@@ -33,7 +34,9 @@ export const GrepTool = Tool.define("grep", {
})
let searchPath = params.path ?? Instance.directory
- searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
+ searchPath = path.isAbsolute(searchPath)
+ ? Filesystem.normalize(searchPath)
+ : Filesystem.resolve(Instance.directory, searchPath)
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
const rgPath = await Ripgrep.filepath()
diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts
index b848e969b7..2d7f318576 100644
--- a/packages/opencode/src/tool/ls.ts
+++ b/packages/opencode/src/tool/ls.ts
@@ -5,6 +5,7 @@ import DESCRIPTION from "./ls.txt"
import { Instance } from "../project/instance"
import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectory } from "./external-directory"
+import { Filesystem } from "../util/filesystem"
export const IGNORE_PATTERNS = [
"node_modules/",
@@ -42,7 +43,7 @@ export const ListTool = Tool.define("list", {
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params, ctx) {
- const searchPath = path.resolve(Instance.directory, params.path || ".")
+ const searchPath = Filesystem.resolve(Instance.directory, params.path || ".")
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
await ctx.ask({
@@ -66,7 +67,7 @@ export const ListTool = Tool.define("list", {
const filesByDir = new Map()
for (const file of files) {
- const dir = path.dirname(file)
+ const dir = Filesystem.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
@@ -90,7 +91,7 @@ export const ListTool = Tool.define("list", {
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
- .filter((d) => path.dirname(d) === dirPath && d !== dirPath)
+ .filter((d) => Filesystem.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
@@ -110,7 +111,7 @@ export const ListTool = Tool.define("list", {
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
- title: path.relative(Instance.worktree, searchPath),
+ title: Filesystem.relative(Instance.worktree, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts
index ca352280b2..7febdde3a9 100644
--- a/packages/opencode/src/tool/lsp.ts
+++ b/packages/opencode/src/tool/lsp.ts
@@ -6,6 +6,7 @@ import DESCRIPTION from "./lsp.txt"
import { Instance } from "../project/instance"
import { pathToFileURL } from "url"
import { assertExternalDirectory } from "./external-directory"
+import { Filesystem } from "../util/filesystem"
const operations = [
"goToDefinition",
@@ -28,7 +29,7 @@ export const LspTool = Tool.define("lsp", {
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
execute: async (args, ctx) => {
- const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
+ const file = path.isAbsolute(args.filePath) ? Filesystem.normalize(args.filePath) : Filesystem.join(Instance.directory, args.filePath)
await assertExternalDirectory(ctx, file)
await ctx.ask({
@@ -44,7 +45,7 @@ export const LspTool = Tool.define("lsp", {
character: args.character - 1,
}
- const relPath = path.relative(Instance.worktree, file)
+ const relPath = Filesystem.relative(Instance.worktree, file)
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
const exists = await Bun.file(file).exists()
diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts
index 7f562f4737..20ecf7a2fc 100644
--- a/packages/opencode/src/tool/multiedit.ts
+++ b/packages/opencode/src/tool/multiedit.ts
@@ -4,6 +4,7 @@ import { EditTool } from "./edit"
import DESCRIPTION from "./multiedit.txt"
import path from "path"
import { Instance } from "../project/instance"
+import { Filesystem } from "../util/filesystem"
export const MultiEditTool = Tool.define("multiedit", {
description: DESCRIPTION,
@@ -36,7 +37,7 @@ export const MultiEditTool = Tool.define("multiedit", {
results.push(result)
}
return {
- title: path.relative(Instance.worktree, params.filePath),
+ title: Filesystem.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},
diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts
index c93a522768..c3b438ff98 100644
--- a/packages/opencode/src/tool/plan.ts
+++ b/packages/opencode/src/tool/plan.ts
@@ -1,5 +1,4 @@
import z from "zod"
-import path from "path"
import { Tool } from "./tool"
import { Question } from "../question"
import { Session } from "../session"
@@ -9,6 +8,7 @@ import { Provider } from "../provider/provider"
import { Instance } from "../project/instance"
import EXIT_DESCRIPTION from "./plan-exit.txt"
import ENTER_DESCRIPTION from "./plan-enter.txt"
+import { Filesystem } from "../util/filesystem"
async function getLastModel(sessionID: string) {
for await (const item of MessageV2.stream(sessionID)) {
@@ -22,7 +22,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
- const plan = path.relative(Instance.worktree, Session.plan(session))
+ const plan = Filesystem.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
@@ -81,7 +81,7 @@ export const PlanEnterTool = Tool.define("plan_enter", {
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
- const plan = path.relative(Instance.worktree, Session.plan(session))
+ const plan = Filesystem.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index 7f5a9a9bd3..f2889dd460 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 { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import { assertExternalDirectory } from "./external-directory"
+import { Filesystem } from "../util/filesystem"
import { InstructionPrompt } from "../session/instruction"
const DEFAULT_READ_LIMIT = 2000
@@ -25,11 +26,11 @@ export const ReadTool = Tool.define("read", {
if (params.offset !== undefined && params.offset < 1) {
throw new Error("offset must be greater than or equal to 1")
}
- let filepath = params.filePath
+ let filepath = Filesystem.normalize(params.filePath)
if (!path.isAbsolute(filepath)) {
- filepath = path.resolve(Instance.directory, filepath)
+ filepath = Filesystem.join(Instance.directory, filepath)
}
- const title = path.relative(Instance.worktree, filepath)
+ const title = Filesystem.relative(Instance.worktree, filepath)
const file = Bun.file(filepath)
const stat = await file.stat().catch(() => undefined)
@@ -47,7 +48,7 @@ export const ReadTool = Tool.define("read", {
})
if (!stat) {
- const dir = path.dirname(filepath)
+ const dir = Filesystem.dirname(filepath)
const base = path.basename(filepath)
const dirEntries = fs.readdirSync(dir)
@@ -56,7 +57,7 @@ export const ReadTool = Tool.define("read", {
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
)
- .map((entry) => path.join(dir, entry))
+ .map((entry) => Filesystem.join(dir, entry))
.slice(0, 3)
if (suggestions.length > 0) {
diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts
index 84e799c131..aac67a7b8d 100644
--- a/packages/opencode/src/tool/truncation.ts
+++ b/packages/opencode/src/tool/truncation.ts
@@ -1,16 +1,16 @@
import fs from "fs/promises"
-import path from "path"
import { Global } from "../global"
import { Identifier } from "../id/id"
import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
+import { Filesystem } from "../util/filesystem"
export namespace Truncate {
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
- export const DIR = path.join(Global.Path.data, "tool-output")
- export const GLOB = path.join(DIR, "*")
+ export const DIR = Filesystem.join(Global.Path.data, "tool-output")
+ export const GLOB = Filesystem.join(DIR, "*")
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const HOUR_MS = 60 * 60 * 1000
@@ -37,7 +37,7 @@ export namespace Truncate {
const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[])
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
- await fs.unlink(path.join(DIR, entry)).catch(() => {})
+ await fs.unlink(Filesystem.join(DIR, entry)).catch(() => {})
}
}
@@ -90,7 +90,7 @@ export namespace Truncate {
const preview = out.join("\n")
const id = Identifier.ascending("tool")
- const filepath = path.join(DIR, id)
+ const filepath = Filesystem.join(DIR, id)
await Bun.write(Bun.file(filepath), text)
const hint = hasTaskTool(agent)
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index eca64d3037..0fb9220390 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -23,7 +23,8 @@ export const WriteTool = Tool.define("write", {
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
}),
async execute(params, ctx) {
- const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
+ const normalized = Filesystem.normalize(params.filePath)
+ const filepath = path.isAbsolute(normalized) ? normalized : Filesystem.resolve(Instance.directory, normalized)
await assertExternalDirectory(ctx, filepath)
const file = Bun.file(filepath)
@@ -34,7 +35,7 @@ export const WriteTool = Tool.define("write", {
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
await ctx.ask({
permission: "edit",
- patterns: [path.relative(Instance.worktree, filepath)],
+ patterns: [Filesystem.relative(Instance.worktree, filepath)],
always: ["*"],
metadata: {
filepath,
@@ -55,7 +56,7 @@ export const WriteTool = Tool.define("write", {
let output = "Wrote file successfully."
await LSP.touchFile(filepath, true)
const diagnostics = await LSP.diagnostics()
- const normalizedFilepath = Filesystem.normalizePath(filepath)
+ const normalizedFilepath = Filesystem.realpath(filepath)
let projectDiagnosticsCount = 0
for (const [file, issues] of Object.entries(diagnostics)) {
const errors = issues.filter((item) => item.severity === 1)
@@ -73,7 +74,7 @@ export const WriteTool = Tool.define("write", {
}
return {
- title: path.relative(Instance.worktree, filepath),
+ title: Filesystem.relative(Instance.worktree, filepath),
metadata: {
diagnostics,
filepath,
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index 7aff6bd1d3..05a17d8882 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -1,5 +1,7 @@
import { realpathSync } from "fs"
-import { dirname, join, relative } from "path"
+import { Flag } from "@/flag/flag"
+import path from "path"
+import { normalize as _normalize } from "@opencode-ai/util/path"
export namespace Filesystem {
export const exists = (p: string) =>
@@ -13,37 +15,59 @@ export namespace Filesystem {
.stat()
.then((s) => s.isDirectory())
.catch(() => false)
+
/**
* On Windows, normalize a path to its canonical casing using the filesystem.
* This is needed because Windows paths are case-insensitive but LSP servers
* may return paths with different casing than what we send them.
*/
- export function normalizePath(p: string): string {
+ export function realpath(p: string): string {
if (process.platform !== "win32") return p
try {
- return realpathSync.native(p)
+ return normalize(realpathSync.native(p))
} catch {
return p
}
}
- export function overlaps(a: string, b: string) {
- const relA = relative(a, b)
- const relB = relative(b, a)
- return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
+
+ /**
+ * Normalize a path to use forward slashes on all platforms.
+ * On Windows, also convert MSYS and Cygwin style paths to Windows drive letter paths.
+ */
+ export function normalize(p: string): string {
+ if (process.platform !== "win32") return p
+ return _normalize(p)
+ }
+
+ export function relative(from: string, to: string) {
+ return normalize(path.relative(normalize(from), normalize(to)))
+ }
+
+ export function resolve(...segments: string[]) {
+ return normalize(path.resolve(...segments))
+ }
+
+ export function join(...segments: string[]) {
+ return normalize(path.join(...segments))
+ }
+
+ export function dirname(p: string) {
+ return normalize(path.dirname(p))
}
export function contains(parent: string, child: string) {
- return !relative(parent, child).startsWith("..")
+ const path = relative(parent, child)
+ return !/^\.\.|.:/.test(path)
}
export async function findUp(target: string, start: string, stop?: string) {
let current = start
const result = []
while (true) {
- const search = join(current, target)
+ const search = normalize(path.join(current, target))
if (await exists(search)) result.push(search)
if (stop === current) break
- const parent = dirname(current)
+ const parent = path.dirname(current)
if (parent === current) break
current = parent
}
@@ -55,11 +79,11 @@ export namespace Filesystem {
let current = start
while (true) {
for (const target of targets) {
- const search = join(current, target)
+ const search = normalize(path.join(current, target))
if (await exists(search)) yield search
}
if (stop === current) break
- const parent = dirname(current)
+ const parent = path.dirname(current)
if (parent === current) break
current = parent
}
@@ -84,7 +108,7 @@ export namespace Filesystem {
// Skip invalid glob patterns
}
if (stop === current) break
- const parent = dirname(current)
+ const parent = path.dirname(current)
if (parent === current) break
current = parent
}
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 0be7061ad9..9954353fa4 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -687,7 +687,11 @@ test("resolves scoped npm plugins in config", async () => {
const config = await Config.get()
const pluginEntries = config.plugin ?? []
- const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
+ // kilocode_change - on Windows, use regular path instead of file:// URL for import.meta.resolve
+ const baseUrl =
+ process.platform === "win32"
+ ? path.join(tmp.path, "opencode.json")
+ : pathToFileURL(path.join(tmp.path, "opencode.json")).href
const expected = import.meta.resolve("@scope/plugin", baseUrl)
expect(pluginEntries.includes(expected)).toBe(true)
diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts
index b1cb30146e..f5f031eb17 100644
--- a/packages/opencode/test/fixture/fixture.ts
+++ b/packages/opencode/test/fixture/fixture.ts
@@ -3,6 +3,7 @@ import * as fs from "fs/promises"
import os from "os"
import path from "path"
import type { Config } from "../../src/config/config"
+import { Filesystem } from "../../src/util/filesystem"
// Strip null bytes from paths (defensive fix for CI environment issues)
function sanitizePath(p: string): string {
@@ -19,8 +20,15 @@ export async function tmpdir(options?: TmpDirOptions) {
const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
await fs.mkdir(dirpath, { recursive: true })
if (options?.git) {
- await $`git init`.cwd(dirpath).quiet()
- await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
+ // kilocode_change - improve error handling for git init
+ const initResult = await $`git init`.cwd(dirpath).quiet().nothrow()
+ if (initResult.exitCode !== 0) {
+ throw new Error(`git init failed: ${initResult.stderr}`)
+ }
+ const commitResult = await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet().nothrow()
+ if (commitResult.exitCode !== 0) {
+ throw new Error(`git commit failed: ${commitResult.stderr}`)
+ }
}
if (options?.config) {
await Bun.write(
@@ -32,7 +40,8 @@ export async function tmpdir(options?: TmpDirOptions) {
)
}
const extra = await options?.init?.(dirpath)
- const realpath = sanitizePath(await fs.realpath(dirpath))
+ // kilocode_change - use Filesystem.normalize for realpath
+ const realpath = Filesystem.normalize(sanitizePath(await fs.realpath(dirpath)))
const result = {
[Symbol.asyncDispose]: async () => {
await options?.dispose?.(dirpath)
diff --git a/packages/opencode/test/ide/ide.test.ts b/packages/opencode/test/ide/ide.test.ts
index 487261357e..73c2a8f41c 100644
--- a/packages/opencode/test/ide/ide.test.ts
+++ b/packages/opencode/test/ide/ide.test.ts
@@ -2,7 +2,8 @@ import { describe, expect, test, afterEach } from "bun:test"
import { Ide } from "../../src/ide"
describe("ide", () => {
- const original = structuredClone(process.env)
+ // kilocode_change - use shallow copy instead of structuredClone for process.env
+ const original = { ...process.env }
afterEach(() => {
Object.keys(process.env).forEach((key) => {
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index 581c63b567..bc495f07bb 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/packages/opencode/test/project/project.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, mock, test } from "bun:test"
import type { Project as ProjectNS } from "../../src/project/project"
import { Log } from "../../src/util/log"
import { Storage } from "../../src/storage/storage"
+import { Filesystem } from "../../src/util/filesystem"
import { $ } from "bun"
import path from "path"
import { tmpdir } from "../fixture/fixture"
@@ -157,10 +158,10 @@ describe("Project.fromDirectory with worktrees", () => {
const { project, sandbox } = await p.fromDirectory(worktreePath)
- expect(project.worktree).toBe(tmp.path)
- expect(sandbox).toBe(worktreePath)
- expect(project.sandboxes).toContain(worktreePath)
- expect(project.sandboxes).not.toContain(tmp.path)
+ expect(Filesystem.normalize(project.worktree)).toBe(Filesystem.normalize(tmp.path))
+ expect(Filesystem.normalize(sandbox)).toBe(Filesystem.normalize(worktreePath))
+ expect(project.sandboxes.map(Filesystem.normalize)).toContain(Filesystem.normalize(worktreePath))
+ expect(project.sandboxes.map(Filesystem.normalize)).not.toContain(Filesystem.normalize(tmp.path))
await $`git worktree remove ${worktreePath}`.cwd(tmp.path).quiet()
})
@@ -177,10 +178,10 @@ describe("Project.fromDirectory with worktrees", () => {
await p.fromDirectory(worktree1)
const { project } = await p.fromDirectory(worktree2)
- expect(project.worktree).toBe(tmp.path)
- expect(project.sandboxes).toContain(worktree1)
- expect(project.sandboxes).toContain(worktree2)
- expect(project.sandboxes).not.toContain(tmp.path)
+ expect(Filesystem.normalize(project.worktree)).toBe(Filesystem.normalize(tmp.path))
+ expect(project.sandboxes.map(Filesystem.normalize)).toContain(Filesystem.normalize(worktree1))
+ expect(project.sandboxes.map(Filesystem.normalize)).toContain(Filesystem.normalize(worktree2))
+ expect(project.sandboxes.map(Filesystem.normalize)).not.toContain(Filesystem.normalize(tmp.path))
await $`git worktree remove ${worktree1}`.cwd(tmp.path).quiet()
await $`git worktree remove ${worktree2}`.cwd(tmp.path).quiet()
diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts
index 110205baf2..b4a50b828e 100644
--- a/packages/opencode/test/session/llm.test.ts
+++ b/packages/opencode/test/session/llm.test.ts
@@ -1,4 +1,4 @@
-import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
+import { afterAll, beforeAll, beforeEach, describe, expect, test, mock } from "bun:test"
import path from "path"
import type { ModelMessage } from "ai"
import { LLM } from "../../src/session/llm"
@@ -11,6 +11,31 @@ import { tmpdir } from "../fixture/fixture"
import type { Agent } from "../../src/agent/agent"
import type { MessageV2 } from "../../src/session/message-v2"
+// Mock BunProc to avoid package installation timeouts in tests
+mock.module("../../src/bun/index", () => ({
+ BunProc: {
+ install: async (pkg: string, _version?: string) => {
+ const lastAtIndex = pkg.lastIndexOf("@")
+ return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg
+ },
+ run: async () => {
+ throw new Error("BunProc.run should not be called in tests")
+ },
+ which: () => process.execPath,
+ InstallFailedError: class extends Error {},
+ },
+}))
+
+// Mock Plugin to avoid loading plugins during provider initialization
+mock.module("../../src/plugin/index", () => ({
+ Plugin: {
+ list: async () => [],
+ load: async () => {},
+ reload: async () => {},
+ trigger: async (_event: string, _context: unknown, input: Record) => input,
+ },
+}))
+
describe("session.llm.hasToolCalls", () => {
test("returns false for empty messages array", () => {
expect(LLM.hasToolCalls([])).toBe(false)
@@ -127,19 +152,19 @@ beforeAll(() => {
state.server = Bun.serve({
port: 0,
async fetch(req) {
- const next = state.queue.shift()
- if (!next) {
+ const url = new URL(req.url)
+ if (state.queue.length === 0) {
return new Response("unexpected request", { status: 500 })
}
- const url = new URL(req.url)
- const body = (await req.json()) as Record
- next.resolve({ url, headers: req.headers, body })
-
- if (!url.pathname.endsWith(next.path)) {
+ const index = state.queue.findIndex((item) => url.pathname.endsWith(item.path))
+ if (index === -1) {
return new Response("not found", { status: 404 })
}
+ const next = state.queue.splice(index, 1)[0]
+ const body = (await req.json()) as Record
+ next.resolve({ url, headers: req.headers, body })
return next.response
},
})
diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts
index 0cf72e182b..1e88f65534 100644
--- a/packages/opencode/test/skill/skill.test.ts
+++ b/packages/opencode/test/skill/skill.test.ts
@@ -4,6 +4,7 @@ import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
+import { Filesystem } from "../../src/util/filesystem"
async function createGlobalSkill(homeDir: string) {
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
@@ -50,7 +51,7 @@ Instructions here.
const testSkill = skills.find((s) => s.name === "test-skill")
expect(testSkill).toBeDefined()
expect(testSkill!.description).toBe("A test skill for verification.")
- expect(testSkill!.location).toContain("skill/test-skill/SKILL.md")
+ expect(Filesystem.normalize(testSkill!.location)).toContain("skill/test-skill/SKILL.md")
},
})
})
@@ -180,7 +181,7 @@ description: A skill in the .claude/skills directory.
expect(skills.length).toBe(1)
const claudeSkill = skills.find((s) => s.name === "claude-skill")
expect(claudeSkill).toBeDefined()
- expect(claudeSkill!.location).toContain(".claude/skills/claude-skill/SKILL.md")
+ expect(Filesystem.normalize(claudeSkill!.location)).toContain(".claude/skills/claude-skill/SKILL.md")
},
})
})
@@ -200,7 +201,7 @@ test("discovers global skills from ~/.claude/skills/ directory", async () => {
expect(skills.length).toBe(1)
expect(skills[0].name).toBe("global-test-skill")
expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.")
- expect(skills[0].location).toContain(".claude/skills/global-test-skill/SKILL.md")
+ expect(Filesystem.normalize(skills[0].location)).toContain(".claude/skills/global-test-skill/SKILL.md")
},
})
} finally {
@@ -245,7 +246,7 @@ description: A skill in the .agents/skills directory.
expect(skills.length).toBe(1)
const agentSkill = skills.find((s) => s.name === "agent-skill")
expect(agentSkill).toBeDefined()
- expect(agentSkill!.location).toContain(".agents/skills/agent-skill/SKILL.md")
+ expect(Filesystem.normalize(agentSkill!.location)).toContain(".agents/skills/agent-skill/SKILL.md")
},
})
})
@@ -279,7 +280,7 @@ This skill is loaded from the global home directory.
expect(skills.length).toBe(1)
expect(skills[0].name).toBe("global-agent-skill")
expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.")
- expect(skills[0].location).toContain(".agents/skills/global-agent-skill/SKILL.md")
+ expect(Filesystem.normalize(skills[0].location)).toContain(".agents/skills/global-agent-skill/SKILL.md")
},
})
} finally {
diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts
index 091469ec76..cf032850e5 100644
--- a/packages/opencode/test/snapshot/snapshot.test.ts
+++ b/packages/opencode/test/snapshot/snapshot.test.ts
@@ -350,7 +350,8 @@ test("very long filenames", async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
- const longName = "a".repeat(200) + ".txt"
+ // kilocode_change - use shorter path on Windows to avoid MAX_PATH issues
+ const longName = process.platform === "win32" ? "a".repeat(100) + ".txt" : "a".repeat(200) + ".txt"
const longFile = `${tmp.path}/${longName}`
await Bun.write(longFile, "long filename content")
@@ -385,6 +386,9 @@ test("hidden files", async () => {
})
test("nested symlinks", async () => {
+ // kilocode_change - skip on Windows (symlinks require admin privileges)
+ if (process.platform === "win32") return
+
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts
index fb4b5ab7b6..d8918c67e8 100644
--- a/packages/opencode/test/tool/bash.test.ts
+++ b/packages/opencode/test/tool/bash.test.ts
@@ -5,6 +5,7 @@ import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import type { PermissionNext } from "../../src/permission/next"
import { Truncate } from "../../src/tool/truncation"
+import { Filesystem } from "../../src/util/filesystem"
const ctx = {
sessionID: "test",
@@ -108,6 +109,7 @@ describe("tool.bash permissions", () => {
requests.push(req)
},
}
+ const parentDir = Filesystem.normalize(path.join(tmp.path, ".."))
await bash.execute(
{
command: "cd ../",
@@ -117,6 +119,7 @@ describe("tool.bash permissions", () => {
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
+ expect(extDirReq!.patterns.some((p) => p.includes(Filesystem.normalize(parentDir)))).toBe(true)
},
})
})
@@ -134,17 +137,18 @@ describe("tool.bash permissions", () => {
requests.push(req)
},
}
+ const tmpDir = process.platform === "win32" ? "C:/Windows/Temp" : "/tmp"
await bash.execute(
{
command: "ls",
- workdir: "/tmp",
- description: "List /tmp",
+ workdir: tmpDir,
+ description: "List tmp directory",
},
testCtx,
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
- expect(extDirReq!.patterns).toContain("/tmp/*")
+ expect(extDirReq!.patterns.some((p) => p.includes(Filesystem.normalize(tmpDir)))).toBe(true)
},
})
})
@@ -176,7 +180,7 @@ describe("tool.bash permissions", () => {
testCtx,
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
- const expected = path.join(outerTmp.path, "*")
+ const expected = Filesystem.normalize(path.join(outerTmp.path, "*"))
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(expected)
expect(extDirReq!.always).toContain(expected)
diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts
index 716dd9a3ff..9e8ae3b1d5 100644
--- a/packages/opencode/test/tool/external-directory.test.ts
+++ b/packages/opencode/test/tool/external-directory.test.ts
@@ -4,6 +4,7 @@ import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory"
import type { PermissionNext } from "../../src/permission/next"
+import { Filesystem } from "../../src/util/filesystem"
const baseCtx: Omit = {
sessionID: "test",
@@ -65,7 +66,7 @@ describe("tool.assertExternalDirectory", () => {
const directory = "/tmp/project"
const target = "/tmp/outside/file.txt"
- const expected = path.join(path.dirname(target), "*")
+ const expected = Filesystem.join(Filesystem.dirname(target), "*")
await Instance.provide({
directory,
@@ -91,7 +92,7 @@ describe("tool.assertExternalDirectory", () => {
const directory = "/tmp/project"
const target = "/tmp/outside"
- const expected = path.join(target, "*")
+ const expected = Filesystem.join(target, "*")
await Instance.provide({
directory,
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index 9102c18f6c..3511a24d8a 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -5,6 +5,7 @@ import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission/next"
import { Agent } from "../../src/agent/agent"
+import { Filesystem } from "../../src/util/filesystem"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -99,7 +100,7 @@ describe("tool.read external_directory permission", () => {
await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
- expect(extDirReq!.patterns).toContain(path.join(outerTmp.path, "external", "*"))
+ expect(extDirReq!.patterns).toContain(Filesystem.normalize(path.join(outerTmp.path, "external", "*")))
},
})
})
diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts
index 0e5f0ba381..798a82c2f1 100644
--- a/packages/opencode/test/util/filesystem.test.ts
+++ b/packages/opencode/test/util/filesystem.test.ts
@@ -36,4 +36,36 @@ describe("util.filesystem", () => {
await rm(tmp, { recursive: true, force: true })
})
+
+ test("normalize() converts paths to posix format", () => {
+ expect(Filesystem.normalize("C:\\Users\\test\\file.txt")).toBe("C:/Users/test/file.txt")
+ expect(Filesystem.normalize("/unix/path/file.txt")).toBe("/unix/path/file.txt")
+ expect(Filesystem.normalize("relative\\path\\file.txt")).toBe("relative/path/file.txt")
+ })
+
+ test("relative() returns posix-style relative paths", () => {
+ const from = "C:\\Users\\test\\project"
+ const to = "C:\\Users\\test\\project\\src\\file.txt"
+ expect(Filesystem.relative(from, to)).toBe("src/file.txt")
+ })
+
+ test("join() produces posix-style paths", () => {
+ expect(Filesystem.join("C:\\Users", "test", "file.txt")).toBe("C:/Users/test/file.txt")
+ expect(Filesystem.join("/unix", "path", "file.txt")).toBe("/unix/path/file.txt")
+ })
+
+ test("dirname() returns posix-style directory", () => {
+ expect(Filesystem.dirname("C:\\Users\\test\\file.txt")).toBe("C:/Users/test")
+ expect(Filesystem.dirname("/unix/path/file.txt")).toBe("/unix/path")
+ })
+
+ test("contains() checks if path is within directory", () => {
+ const dir = "C:\\Users\\test\\project"
+ const inside = "C:\\Users\\test\\project\\src\\file.txt"
+ const outside = "C:\\Users\\other\\file.txt"
+
+ expect(Filesystem.contains(dir, inside)).toBe(true)
+ expect(Filesystem.contains(dir, outside)).toBe(false)
+ expect(Filesystem.contains(dir, dir)).toBe(true)
+ })
})
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index f0d9daa94e..3d1cec2d13 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -43,7 +43,7 @@ import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
import { ImagePreview } from "./image-preview"
import { findLast } from "@opencode-ai/util/array"
-import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
+import { getDirectory as _getDirectory, getFilename, normalize } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
@@ -64,7 +64,9 @@ function getDiagnostics(
filePath: string | undefined,
): Diagnostic[] {
if (!diagnosticsByFile || !filePath) return []
- const diagnostics = diagnosticsByFile[filePath] ?? []
+ // kilocode_change - normalize filePath for diagnostics lookup
+ const normalizedPath = normalize(filePath)
+ const diagnostics = diagnosticsByFile[normalizedPath] ?? diagnosticsByFile[filePath] ?? []
return diagnostics.filter((d) => d.severity === 1).slice(0, 3)
}
diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts
index bb191f5120..1e80b7ecae 100644
--- a/packages/util/src/path.ts
+++ b/packages/util/src/path.ts
@@ -35,3 +35,11 @@ export function truncateMiddle(text: string, maxLength: number = 20) {
const end = Math.floor(available / 2)
return text.slice(0, start) + "…" + text.slice(-end)
}
+
+/**
+ * Normalize a path to use forward slashes on all platforms.
+ * On Windows, also convert MSYS and Cygwin style paths to Windows drive letter paths.
+ */
+export function normalize(p: string): string {
+ return p.replace(/^\/(?:cygdrive\/)?([a-zA-Z])(\/|$)/, (_, d) => `${d.toUpperCase()}:/`).replace(/\\+/g, "/")
+}
From f3d64a46984aed2058763ba5e2a4b001dc6c468c Mon Sep 17 00:00:00 2001
From: ops
Date: Fri, 20 Feb 2026 19:01:44 +0100
Subject: [PATCH 2/7] fix: invalid version range in registry test
---
packages/opencode/test/tool/registry.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts
index 0f7ffcec4a..849e78b07a 100644
--- a/packages/opencode/test/tool/registry.test.ts
+++ b/packages/opencode/test/tool/registry.test.ts
@@ -88,7 +88,7 @@ describe("tool.registry", () => {
JSON.stringify({
name: "custom-tools",
dependencies: {
- "@kilocode/plugin": "^0.0.0",
+ "@kilocode/plugin": "*",
cowsay: "^1.6.0",
},
}),
From 25763537d952849250627fa308915f7b92a442ac Mon Sep 17 00:00:00 2001
From: ops
Date: Fri, 20 Feb 2026 19:14:18 +0100
Subject: [PATCH 3/7] fix: remove unused import and skip win32 tests on
non-windows
---
packages/opencode/src/util/filesystem.ts | 1 -
packages/opencode/test/util/filesystem.test.ts | 10 +++++-----
2 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index 05a17d8882..45adc65929 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -1,5 +1,4 @@
import { realpathSync } from "fs"
-import { Flag } from "@/flag/flag"
import path from "path"
import { normalize as _normalize } from "@opencode-ai/util/path"
diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts
index 798a82c2f1..c13fd2f30c 100644
--- a/packages/opencode/test/util/filesystem.test.ts
+++ b/packages/opencode/test/util/filesystem.test.ts
@@ -37,29 +37,29 @@ describe("util.filesystem", () => {
await rm(tmp, { recursive: true, force: true })
})
- test("normalize() converts paths to posix format", () => {
+ test.skipIf(process.platform !== "win32")("normalize() converts paths to posix format", () => {
expect(Filesystem.normalize("C:\\Users\\test\\file.txt")).toBe("C:/Users/test/file.txt")
expect(Filesystem.normalize("/unix/path/file.txt")).toBe("/unix/path/file.txt")
expect(Filesystem.normalize("relative\\path\\file.txt")).toBe("relative/path/file.txt")
})
- test("relative() returns posix-style relative paths", () => {
+ test.skipIf(process.platform !== "win32")("relative() returns posix-style relative paths", () => {
const from = "C:\\Users\\test\\project"
const to = "C:\\Users\\test\\project\\src\\file.txt"
expect(Filesystem.relative(from, to)).toBe("src/file.txt")
})
- test("join() produces posix-style paths", () => {
+ test.skipIf(process.platform !== "win32")("join() produces posix-style paths", () => {
expect(Filesystem.join("C:\\Users", "test", "file.txt")).toBe("C:/Users/test/file.txt")
expect(Filesystem.join("/unix", "path", "file.txt")).toBe("/unix/path/file.txt")
})
- test("dirname() returns posix-style directory", () => {
+ test.skipIf(process.platform !== "win32")("dirname() returns posix-style directory", () => {
expect(Filesystem.dirname("C:\\Users\\test\\file.txt")).toBe("C:/Users/test")
expect(Filesystem.dirname("/unix/path/file.txt")).toBe("/unix/path")
})
- test("contains() checks if path is within directory", () => {
+ test.skipIf(process.platform !== "win32")("contains() checks if path is within directory", () => {
const dir = "C:\\Users\\test\\project"
const inside = "C:\\Users\\test\\project\\src\\file.txt"
const outside = "C:\\Users\\other\\file.txt"
From 27843ef5719aafd48717f3f2365665ceaafda9f1 Mon Sep 17 00:00:00 2001
From: Patrick Schiel
Date: Fri, 20 Feb 2026 19:20:22 +0100
Subject: [PATCH 4/7] Update packages/opencode/src/util/filesystem.ts
Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
---
packages/opencode/src/util/filesystem.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index 45adc65929..a9adf39f40 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -56,7 +56,7 @@ export namespace Filesystem {
export function contains(parent: string, child: string) {
const path = relative(parent, child)
- return !/^\.\.|.:/.test(path)
+ return !/^\.\.|^.:/.test(path)
}
export async function findUp(target: string, start: string, stop?: string) {
From 5866348bbb673ad551ce633ed7dfc3bd8763dabb Mon Sep 17 00:00:00 2001
From: ops
Date: Fri, 20 Feb 2026 19:26:28 +0100
Subject: [PATCH 5/7] fix: normalize parent directory in findUp/up/globUp
functions
---
packages/opencode/src/util/filesystem.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index 45adc65929..da0312746e 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -66,7 +66,7 @@ export namespace Filesystem {
const search = normalize(path.join(current, target))
if (await exists(search)) result.push(search)
if (stop === current) break
- const parent = path.dirname(current)
+ const parent = normalize(path.dirname(current))
if (parent === current) break
current = parent
}
@@ -82,7 +82,7 @@ export namespace Filesystem {
if (await exists(search)) yield search
}
if (stop === current) break
- const parent = path.dirname(current)
+ const parent = normalize(path.dirname(current))
if (parent === current) break
current = parent
}
@@ -107,7 +107,7 @@ export namespace Filesystem {
// Skip invalid glob patterns
}
if (stop === current) break
- const parent = path.dirname(current)
+ const parent = normalize(path.dirname(current))
if (parent === current) break
current = parent
}
From 80b82b29c7be9a8948c00fe50e5f31c6617408df Mon Sep 17 00:00:00 2001
From: ops
Date: Fri, 20 Feb 2026 19:35:39 +0100
Subject: [PATCH 6/7] fix: fixed resolve vs join, added guard to path.ts
normalize, applied suggestions from cr
---
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx | 2 +-
packages/opencode/src/tool/read.ts | 2 +-
packages/opencode/src/util/filesystem.ts | 2 +-
packages/util/src/path.ts | 1 +
4 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
index 27ef28409e..1251677821 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
@@ -24,7 +24,7 @@ function normalizePath(input?: string) {
const cwd = process.cwd()
const home = Global.Path.home
- const absolute = path.isAbsolute(input) ? input : Filesystem.resolve(cwd, input)
+ const absolute = Filesystem.normalize(path.isAbsolute(input) ? input : Filesystem.resolve(cwd, input))
const relative = Filesystem.relative(cwd, absolute)
if (!relative) return "."
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index f2889dd460..b48cd58673 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -28,7 +28,7 @@ export const ReadTool = Tool.define("read", {
}
let filepath = Filesystem.normalize(params.filePath)
if (!path.isAbsolute(filepath)) {
- filepath = Filesystem.join(Instance.directory, filepath)
+ filepath = Filesystem.resolve(Instance.directory, filepath)
}
const title = Filesystem.relative(Instance.worktree, filepath)
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index fbbc280ccf..78c2eb3f44 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -25,7 +25,7 @@ export namespace Filesystem {
try {
return normalize(realpathSync.native(p))
} catch {
- return p
+ return normalize(p)
}
}
diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts
index 1e80b7ecae..ca5bfc0ca4 100644
--- a/packages/util/src/path.ts
+++ b/packages/util/src/path.ts
@@ -41,5 +41,6 @@ export function truncateMiddle(text: string, maxLength: number = 20) {
* On Windows, also convert MSYS and Cygwin style paths to Windows drive letter paths.
*/
export function normalize(p: string): string {
+ if (process.platform !== "win32") return p
return p.replace(/^\/(?:cygdrive\/)?([a-zA-Z])(\/|$)/, (_, d) => `${d.toUpperCase()}:/`).replace(/\\+/g, "/")
}
From ede82dfc0d0fa7d90d4310f410776c6bdf97180f Mon Sep 17 00:00:00 2001
From: ops
Date: Fri, 20 Feb 2026 19:37:51 +0100
Subject: [PATCH 7/7] fix: use '/' instead of path.sep in permission.tsx
---
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
index 1251677821..58dea17c19 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
@@ -31,7 +31,7 @@ function normalizePath(input?: string) {
if (!relative.startsWith("..")) return relative
// outside cwd - use ~ or absolute
- if (home && (absolute === home || absolute.startsWith(home + path.sep))) {
+ if (home && (absolute === home || absolute.startsWith(home + '/'))) {
return absolute.replace(home, "~")
}
return absolute