From 1b6b8297699642ce6c2d94032784e839f65fd452 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:34:13 +1000 Subject: [PATCH 01/57] fix(windows): add posix path normalizer and shim --- packages/opencode/src/util/path.ts | 25 +++++++++++++++ packages/opencode/test/util/path.test.ts | 39 ++++++++++++++++++++++++ packages/util/src/path.ts | 19 ++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 packages/opencode/src/util/path.ts create mode 100644 packages/opencode/test/util/path.test.ts diff --git a/packages/opencode/src/util/path.ts b/packages/opencode/src/util/path.ts new file mode 100644 index 000000000000..75b0b77aa533 --- /dev/null +++ b/packages/opencode/src/util/path.ts @@ -0,0 +1,25 @@ +import nodePath from "node:path" + +import { toPosix } from "@opencode-ai/util/path" + +const isWin = process.platform === "win32" + +function normalizeArgs(args: string[]) { + if (!isWin) return args + return args.map((x) => toPosix(x)) +} + +export { toPosix } + +export default { + ...nodePath, + + join: (...args: string[]) => (isWin ? toPosix(nodePath.join(...normalizeArgs(args))) : nodePath.join(...args)), + resolve: (...args: string[]) => + isWin ? toPosix(nodePath.resolve(...normalizeArgs(args))) : nodePath.resolve(...args), + normalize: (p: string) => (isWin ? toPosix(nodePath.normalize(toPosix(p))) : nodePath.normalize(p)), + relative: (from: string, to: string) => + isWin ? toPosix(nodePath.relative(toPosix(from), toPosix(to))) : nodePath.relative(from, to), + + toPosix, +} diff --git a/packages/opencode/test/util/path.test.ts b/packages/opencode/test/util/path.test.ts new file mode 100644 index 000000000000..0bb38f22239c --- /dev/null +++ b/packages/opencode/test/util/path.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import path from "../../src/util/path" + +const isWin = process.platform === "win32" + +describe("util.path", () => { + test("normalizes standard paths", () => { + const input = isWin ? "a\\b\\c" : "a/b/c" + expect(path.normalize(input)).toBe("a/b/c") + }) + + test("joins with forward slashes", () => { + expect(path.join("a", "b")).toBe("a/b") + }) + + test("resolve() output contains no backslashes", () => { + const res = path.resolve(".") + expect(res).not.toContain("\\") + }) + + if (!isWin) return + + test("converts Git Bash / MSYS paths", () => { + expect(path.toPosix("/c/Users/Luke")).toBe("C:/Users/Luke") + expect(path.toPosix("/d/Project")).toBe("D:/Project") + expect(path.toPosix("/cygdrive/e/src")).toBe("E:/src") + expect(path.toPosix("/mnt/f/dev")).toBe("F:/dev") + }) + + test("preserves UNC paths", () => { + const unc = "\\\\server\\share\\file.txt" + expect(path.toPosix(unc)).toBe("//server/share/file.txt") + }) + + test("handles mixed slashes", () => { + expect(path.toPosix("C:/Users/Luke\\dev")).toBe("C:/Users/Luke/dev") + expect(path.toPosix("c:\\Users\\Luke\\dev")).toBe("C:/Users/Luke/dev") + }) +}) diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index bb191f5120ab..66399bccffc4 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -5,6 +5,25 @@ export function getFilename(path: string | undefined) { return parts[parts.length - 1] ?? "" } +const isWin = + (typeof process !== "undefined" && process.platform === "win32") || + (typeof navigator !== "undefined" && /\bWindows\b/i.test(navigator.userAgent)) + +/** + * Normalize Windows paths to be Bash/LLM friendly. + * + * - Forces '/' separators (C:/foo/bar) + * - Converts MSYS/Cygwin/WSL roots (/c, /cygdrive/c, /mnt/c) -> C:/ + * - Preserves UNC shares (//server/share) + */ +export function toPosix(p: string) { + if (!isWin) return p + + const slashed = p.replace(/\\/g, "/") + const msys = slashed.replace(/^\/(?:cygdrive\/|mnt\/)?([a-zA-Z])(?:\/|$)/, (_, d) => `${d.toUpperCase()}:/`) + return msys.replace(/^([a-z]):\//, (_, d) => `${d.toUpperCase()}:/`) +} + export function getDirectory(path: string | undefined) { if (!path) return "" const trimmed = path.replace(/[\/\\]+$/, "") From f134ae35db9a61382234272ec02ba9cfa11a221d Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:39:35 +1000 Subject: [PATCH 02/57] fix(windows): make Filesystem.contains drive-aware --- packages/opencode/src/util/filesystem.ts | 51 +++++++++++++++---- .../opencode/test/file/path-traversal.test.ts | 12 +++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 7aff6bd1d302..118ddd2d221c 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,5 +1,6 @@ import { realpathSync } from "fs" -import { dirname, join, relative } from "path" + +import path, { toPosix } from "@/util/path" export namespace Filesystem { export const exists = (p: string) => @@ -21,29 +22,57 @@ export namespace Filesystem { export function normalizePath(p: string): string { if (process.platform !== "win32") return p try { - return realpathSync.native(p) + return toPosix(realpathSync.native(toPosix(p))) } catch { - return p + return toPosix(p) + } + } + + function root(p: string) { + const normalized = toPosix(p) + + const drive = normalized.match(/^([a-zA-Z]):\//) + if (drive) return `${drive[1].toUpperCase()}:/` + + if (!normalized.startsWith("//")) { + if (normalized.startsWith("/")) return "/" + return "" } + + const parts = normalized.split("/").filter(Boolean) + if (parts.length < 2) return "//" + return `//${parts[0]}/${parts[1]}/` } export function overlaps(a: string, b: string) { - const relA = relative(a, b) - const relB = relative(b, a) + const relA = path.relative(toPosix(a), toPosix(b)) + const relB = path.relative(toPosix(b), toPosix(a)) return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + const p = toPosix(parent) + const c = toPosix(child) + + if (process.platform === "win32") { + const rp = root(p).toLowerCase() + const rc = root(c).toLowerCase() + if (rp && rc && rp !== rc) return false + } + + const rel = path.relative(p, c) + if (!rel) return true + if (rel === ".." || rel.startsWith("../")) return false + return !path.isAbsolute(rel) } export async function findUp(target: string, start: string, stop?: string) { let current = start const result = [] while (true) { - const search = join(current, target) + const search = 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 +84,11 @@ export namespace Filesystem { let current = start while (true) { for (const target of targets) { - const search = join(current, target) + const search = 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 +113,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/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 44ae8f154357..2f77ab89285d 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -29,6 +29,18 @@ describe("Filesystem.contains", () => { expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) expect(Filesystem.contains("/project", "/projectfile")).toBe(false) }) + + if (process.platform !== "win32") return + + test("windows: blocks cross-drive paths", () => { + expect(Filesystem.contains("C:/project", "D:/file")).toBe(false) + expect(Filesystem.contains("C:/project", "C:/project/file")).toBe(true) + }) + + test("windows: treats UNC shares as distinct roots", () => { + expect(Filesystem.contains("//server/share", "C:/project/file")).toBe(false) + expect(Filesystem.contains("//server/share", "//server/share/dir/file")).toBe(true) + }) }) /* From 78d2bf0e04aed63279df1508b764c124974dfe1c Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:43:15 +1000 Subject: [PATCH 03/57] fix(windows): normalize Instance directory paths --- packages/opencode/src/project/instance.ts | 12 +++++++----- packages/opencode/src/project/project.ts | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 98031f18d3f1..cf90473f8ed0 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -5,6 +5,7 @@ import { State } from "./state" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { Filesystem } from "@/util/filesystem" +import path from "@/util/path" interface Context { directory: string @@ -20,13 +21,14 @@ const disposal = { export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - let existing = cache.get(input.directory) + const directory = path.resolve(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, worktree: sandbox, project, } @@ -35,7 +37,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 f6902de4e1bd..6ec6cf8c2512 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,7 +1,7 @@ import z from "zod" import fs from "fs/promises" import { Filesystem } from "../util/filesystem" -import path from "path" +import path from "@/util/path" import { $ } from "bun" import { Storage } from "../storage/storage" import { Log } from "../util/log" From 7900d75d9405360d37da13bf1a8c2dc41028da4e Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:07:13 +1000 Subject: [PATCH 04/57] fix(windows): use posix path shim across opencode --- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/auth/index.ts | 2 +- packages/opencode/src/bun/index.ts | 2 +- packages/opencode/src/cli/cmd/agent.ts | 2 +- packages/opencode/src/cli/cmd/auth.ts | 2 +- packages/opencode/src/cli/cmd/debug/agent.ts | 5 ++-- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/mcp.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 2 +- packages/opencode/src/cli/cmd/session.ts | 3 +- packages/opencode/src/cli/cmd/tui/thread.ts | 2 +- .../src/cli/cmd/tui/util/clipboard.ts | 3 +- .../opencode/src/cli/cmd/tui/util/editor.ts | 4 +-- packages/opencode/src/cli/cmd/uninstall.ts | 2 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/file/ignore.ts | 10 ++++--- packages/opencode/src/file/index.ts | 2 +- packages/opencode/src/file/ripgrep.ts | 23 +++++++-------- packages/opencode/src/file/watcher.ts | 2 +- packages/opencode/src/format/index.ts | 2 +- packages/opencode/src/global/index.ts | 2 +- packages/opencode/src/installation/index.ts | 2 +- packages/opencode/src/lsp/client.ts | 2 +- packages/opencode/src/lsp/index.ts | 2 +- packages/opencode/src/lsp/server.ts | 2 +- packages/opencode/src/mcp/auth.ts | 2 +- packages/opencode/src/patch/index.ts | 2 +- packages/opencode/src/project/vcs.ts | 2 +- packages/opencode/src/provider/models.ts | 2 +- packages/opencode/src/session/index.ts | 2 +- packages/opencode/src/session/instruction.ts | 2 +- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/session/summary.ts | 2 +- packages/opencode/src/shell/shell.ts | 2 +- packages/opencode/src/skill/skill.ts | 2 +- packages/opencode/src/snapshot/index.ts | 2 +- packages/opencode/src/storage/storage.ts | 2 +- packages/opencode/src/tool/apply_patch.ts | 2 +- packages/opencode/src/tool/bash.ts | 2 +- packages/opencode/src/tool/edit.ts | 2 +- .../opencode/src/tool/external-directory.ts | 2 +- packages/opencode/src/tool/glob.ts | 2 +- packages/opencode/src/tool/grep.ts | 2 +- packages/opencode/src/tool/ls.ts | 2 +- packages/opencode/src/tool/lsp.ts | 2 +- packages/opencode/src/tool/multiedit.ts | 2 +- packages/opencode/src/tool/plan.ts | 2 +- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/skill.ts | 2 +- packages/opencode/src/tool/truncation.ts | 2 +- packages/opencode/src/tool/write.ts | 2 +- packages/opencode/src/util/archive.ts | 3 +- packages/opencode/src/util/log.ts | 2 +- packages/opencode/src/worktree/index.ts | 2 +- packages/opencode/test/preload.ts | 4 ++- .../test/tool/external-directory.test.ts | 29 ++++++++++++------- packages/opencode/test/tool/read.test.ts | 5 ++-- 58 files changed, 97 insertions(+), 88 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 1d90a4c3656b..ac8d73e09fc2 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -16,7 +16,7 @@ import PROMPT_TITLE from "./prompt/title.txt" import { PermissionNext } from "@/permission/next" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" -import path from "path" +import path from "@/util/path" import { Plugin } from "@/plugin" export namespace Agent { diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index ce948b92ac80..3c2685d6e5b7 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,4 +1,4 @@ -import path from "path" +import path from "@/util/path" import { Global } from "../global" import z from "zod" diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index a18bbd14d8ab..f7b953d4a59f 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -1,7 +1,7 @@ import z from "zod" import { Global } from "../global" import { Log } from "../util/log" -import path from "path" +import path from "@/util/path" import { Filesystem } from "../util/filesystem" import { NamedError } from "@opencode-ai/util/error" import { readableStreamToText } from "bun" diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index e5da9fdb386c..54d4dbd0348f 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -4,7 +4,7 @@ import { UI } from "../ui" import { Global } from "../../global" import { Agent } from "../../agent/agent" import { Provider } from "../../provider/provider" -import path from "path" +import path from "@/util/path" import fs from "fs/promises" import matter from "gray-matter" import { Instance } from "../../project/instance" diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index bbaecfd8c711..5584d23710d4 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -4,7 +4,7 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { ModelsDev } from "../../provider/models" import { map, pipe, sortBy, values } from "remeda" -import path from "path" +import path from "@/util/path" import os from "os" import { Config } from "../../config/config" import { Global } from "../../global" diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index fe3003485976..621d41c5f17b 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import { basename } from "path" +import path from "@/util/path" import { Agent } from "../../../agent/agent" import { Provider } from "../../../provider/provider" import { Session } from "../../../session" @@ -36,7 +36,8 @@ export const AgentCommand = cmd({ const agent = await Agent.get(agentName) if (!agent) { process.stderr.write( - `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL, + `Agent ${agentName} not found, run '${path.basename(process.execPath)} agent list' to get an agent list` + + EOL, ) process.exit(1) } diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 927c964c9d8b..6d42846e4332 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -1,4 +1,4 @@ -import path from "path" +import path from "@/util/path" import { exec } from "child_process" import * as prompts from "@clack/prompts" import { map, pipe, sortBy, values } from "remeda" diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 95719215e324..e9672e3f21fe 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -10,7 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "../../config/config" import { Instance } from "../../project/instance" import { Installation } from "../../installation" -import path from "path" +import path from "@/util/path" import { Global } from "../../global" import { modify, applyEdits } from "jsonc-parser" import { Bus } from "../../bus" diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 54248f96f3df..f09516ac834f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,5 +1,5 @@ import type { Argv } from "yargs" -import path from "path" +import path from "@/util/path" import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index c6a1fd4138f2..6f64ca5f62b3 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -6,8 +6,7 @@ import { UI } from "../ui" import { Locale } from "../../util/locale" import { Flag } from "../../flag/flag" import { EOL } from "os" -import path from "path" - +import path from "@/util/path" function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] if (process.platform !== "win32") { diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 05714268545b..2c32a9c049cf 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -2,7 +2,7 @@ import { cmd } from "@/cli/cmd/cmd" import { tui } from "./app" import { Rpc } from "@/util/rpc" import { type rpc } from "./worker" -import path from "path" +import path from "@/util/path" import { UI } from "@/cli/ui" import { iife } from "@/util/iife" import { Log } from "@/util/log" diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 0e287fbc41ae..c4e9ed297ad6 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -3,8 +3,7 @@ import { platform, release } from "os" import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" -import path from "path" - +import path from "@/util/path" /** * Writes text to clipboard via OSC 52 escape sequence. * This allows clipboard operations to work over SSH by having diff --git a/packages/opencode/src/cli/cmd/tui/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts index f98e24b06959..3653f4aa233b 100644 --- a/packages/opencode/src/cli/cmd/tui/util/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/util/editor.ts @@ -1,7 +1,7 @@ import { defer } from "@/util/defer" import { rm } from "node:fs/promises" import { tmpdir } from "node:os" -import { join } from "node:path" +import path from "@/util/path" import { CliRenderer } from "@opentui/core" export namespace Editor { @@ -9,7 +9,7 @@ export namespace Editor { const editor = process.env["VISUAL"] || process.env["EDITOR"] if (!editor) return - const filepath = join(tmpdir(), `${Date.now()}.md`) + const filepath = path.join(tmpdir(), `${Date.now()}.md`) await using _ = defer(async () => rm(filepath, { force: true })) await Bun.write(filepath, opts.value) diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index 704d3572bbb4..dba1a0ce2007 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -5,7 +5,7 @@ import { Installation } from "../../installation" import { Global } from "../../global" import { $ } from "bun" import fs from "fs/promises" -import path from "path" +import path from "@/util/path" import os from "os" interface UninstallArgs { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8c65726e2364..7932b3614ca1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,5 +1,5 @@ import { Log } from "../util/log" -import path from "path" +import path from "@/util/path" import { pathToFileURL } from "url" import os from "os" import z from "zod" diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index 7230f67afeb8..41647bb649d7 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 { toPosix } from "@/util/path" export namespace FileIgnore { const FOLDERS = new Set([ @@ -64,18 +64,20 @@ export namespace FileIgnore { whitelist?: Bun.Glob[] }, ) { + const target = toPosix(filepath) + for (const glob of opts?.whitelist || []) { - if (glob.match(filepath)) return false + if (glob.match(target)) return false } - const parts = filepath.split(sep) + const parts = target.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(target)) return true } return false diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index dfa6356a274b..4391b1108390 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -3,7 +3,7 @@ import z from "zod" import { $ } from "bun" import type { BunFile } from "bun" import { formatPatch, structuredPatch } from "diff" -import path from "path" +import path from "@/util/path" import fs from "fs" import ignore from "ignore" import { Log } from "../util/log" diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 0d18173565e1..d332bd8ed537 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -1,5 +1,5 @@ // Ripgrep utility functions -import path from "path" +import path from "@/util/path" import { Global } from "../global" import fs from "fs/promises" import z from "zod" @@ -12,6 +12,9 @@ import { Log } from "@/util/log" export namespace Ripgrep { const log = Log.create({ service: "ripgrep" }) + const FilePath = z.object({ + text: z.string().transform((x) => path.toPosix(x)), + }) const Stats = z.object({ elapsed: z.object({ secs: z.number(), @@ -29,18 +32,14 @@ export namespace Ripgrep { const Begin = z.object({ type: z.literal("begin"), data: z.object({ - path: z.object({ - text: z.string(), - }), + path: FilePath, }), }) export const Match = z.object({ type: z.literal("match"), data: z.object({ - path: z.object({ - text: z.string(), - }), + path: FilePath, lines: z.object({ text: z.string(), }), @@ -61,9 +60,7 @@ export namespace Ripgrep { const End = z.object({ type: z.literal("end"), data: z.object({ - path: z.object({ - text: z.string(), - }), + path: FilePath, binary_offset: z.number().nullable(), stats: Stats, }), @@ -252,11 +249,11 @@ export namespace Ripgrep { buffer = lines.pop() || "" for (const line of lines) { - if (line) yield line + if (line) yield path.toPosix(line) } } - if (buffer) yield buffer + if (buffer) yield path.toPosix(buffer) } finally { reader.releaseLock() await proc.exited @@ -295,7 +292,7 @@ export namespace Ripgrep { } for (const file of files) { if (file.includes(".opencode")) continue - const parts = file.split(path.sep) + const parts = file.split("/") getPath(root, parts, true) } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index c4a4747777e2..70231ec1076f 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -5,7 +5,7 @@ import { Instance } from "../project/instance" import { Log } from "../util/log" import { FileIgnore } from "./ignore" import { Config } from "../config/config" -import path from "path" +import path from "@/util/path" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import { lazy } from "@/util/lazy" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index bab758030b9f..f76970c383e4 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,7 +1,7 @@ import { Bus } from "../bus" import { File } from "../file" import { Log } from "../util/log" -import path from "path" +import path from "@/util/path" import z from "zod" import * as Formatter from "./formatter" diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 10b6125a6a93..ab90542dc8da 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -1,6 +1,6 @@ import fs from "fs/promises" import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" -import path from "path" +import path from "@/util/path" import os from "os" const app = "opencode" diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index d18c9e31a13b..0d71a3b521d9 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,5 +1,5 @@ import { BusEvent } from "@/bus/bus-event" -import path from "path" +import path from "@/util/path" import { $ } from "bun" import z from "zod" import { NamedError } from "@opencode-ai/util/error" diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 8704b65acb5b..6d002a7f4586 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import path from "path" +import path from "@/util/path" import { pathToFileURL, fileURLToPath } from "url" import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types" diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 0fd3b69dfcd9..774dacbcb2b3 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -2,7 +2,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Log } from "../util/log" import { LSPClient } from "./client" -import path from "path" +import path from "@/util/path" import { pathToFileURL } from "url" import { LSPServer } from "./server" import z from "zod" diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index e7efd99dcbd9..35aae56bc90c 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from "child_process" -import path from "path" +import path from "@/util/path" import os from "os" import { Global } from "../global" import { Log } from "../util/log" diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 0f91a35b8754..f64e6c3d8aa1 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -1,4 +1,4 @@ -import path from "path" +import path from "@/util/path" import z from "zod" import { Global } from "../global" diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 0efeff544f66..d9d9b344c8cc 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -1,5 +1,5 @@ import z from "zod" -import * as path from "path" +import path from "@/util/path" import * as fs from "fs/promises" import { readFileSync } from "fs" import { Log } from "../util/log" diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index e434b5f8c3a8..0a15d742d970 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { $ } from "bun" -import path from "path" +import path from "@/util/path" import z from "zod" import { Log } from "@/util/log" import { Instance } from "./instance" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 82794f35baa6..49808d3a920d 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -1,6 +1,6 @@ import { Global } from "../global" import { Log } from "../util/log" -import path from "path" +import path from "@/util/path" import z from "zod" import { Installation } from "../installation" import { Flag } from "../flag/flag" diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index fb0836bfb78b..141f9585a33f 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,5 +1,5 @@ import { Slug } from "@opencode-ai/util/slug" -import path from "path" +import path from "@/util/path" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 723439a3fdb2..f08d4f646af5 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,4 +1,4 @@ -import path from "path" +import path from "@/util/path" import os from "os" import { Global } from "../global" import { Filesystem } from "../util/filesystem" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 94eabdef7f48..b8e95588b834 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,4 +1,4 @@ -import path from "path" +import path from "@/util/path" import os from "os" import fs from "fs/promises" import z from "zod" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 91a520a9bdfa..efbfd2f74972 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -9,7 +9,7 @@ import { Identifier } from "@/id/id" import { Snapshot } from "@/snapshot" import { Log } from "@/util/log" -import path from "path" +import path from "@/util/path" import { Instance } from "@/project/instance" import { Storage } from "@/storage/storage" import { Bus } from "@/bus" diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index 2e8d48bfd921..bb84c7447ea4 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -1,6 +1,6 @@ import { Flag } from "@/flag/flag" import { lazy } from "@/util/lazy" -import path from "path" +import path from "@/util/path" import { spawn, type ChildProcess } from "child_process" const SIGKILL_TIMEOUT_MS = 200 diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 12fc9ee90c7d..50986a2db00b 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -1,5 +1,5 @@ import z from "zod" -import path from "path" +import path from "@/util/path" import { Config } from "../config/config" import { Instance } from "../project/instance" import { NamedError } from "@opencode-ai/util/error" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 1c1539090542..48cda020fbe6 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,5 +1,5 @@ import { $ } from "bun" -import path from "path" +import path from "@/util/path" import fs from "fs/promises" import { Log } from "../util/log" import { Global } from "../global" diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 18f2d67e7ac0..b9e6d454753e 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -1,5 +1,5 @@ import { Log } from "../util/log" -import path from "path" +import path from "@/util/path" import fs from "fs/promises" import { Global } from "../global" import { Filesystem } from "../util/filesystem" diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 1344467c719f..c2d67db4b7ba 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -1,5 +1,5 @@ import z from "zod" -import * as path from "path" +import path from "@/util/path" import * as fs from "fs/promises" import { Tool } from "./tool" import { Bus } from "../bus" diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index bf7c524941fe..4ce479bde356 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,7 +1,7 @@ import z from "zod" import { spawn } from "child_process" import { Tool } from "./tool" -import path from "path" +import path from "@/util/path" import DESCRIPTION from "./bash.txt" import { Log } from "../util/log" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 0bf1d6792bc2..c3cebcb76915 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -4,7 +4,7 @@ // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts import z from "zod" -import * as path from "path" +import path from "@/util/path" import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 1d3958fc464f..e32cae2d7dfe 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -1,4 +1,4 @@ -import path from "path" +import path from "@/util/path" import type { Tool } from "./tool" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index dda57f6ee1b9..601e5c0f9e28 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -1,5 +1,5 @@ import z from "zod" -import path from "path" +import path from "@/util/path" import { Tool } from "./tool" import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 097dedf4aafc..aac34167f8ae 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -4,7 +4,7 @@ import { Ripgrep } from "../file/ripgrep" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" -import path from "path" +import path from "@/util/path" import { assertExternalDirectory } from "./external-directory" const MAX_LINE_LENGTH = 2000 diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index cc3d750078f1..dd7a34e21fec 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -1,6 +1,6 @@ import z from "zod" import { Tool } from "./tool" -import * as path from "path" +import path from "@/util/path" import DESCRIPTION from "./ls.txt" import { Instance } from "../project/instance" import { Ripgrep } from "../file/ripgrep" diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index ca352280b2a9..06b9654b85f6 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -1,6 +1,6 @@ import z from "zod" import { Tool } from "./tool" -import path from "path" +import path from "@/util/path" import { LSP } from "../lsp" import DESCRIPTION from "./lsp.txt" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 7f562f4737ab..b136c53c81b8 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -2,7 +2,7 @@ import z from "zod" import { Tool } from "./tool" import { EditTool } from "./edit" import DESCRIPTION from "./multiedit.txt" -import path from "path" +import path from "@/util/path" import { Instance } from "../project/instance" export const MultiEditTool = Tool.define("multiedit", { diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index 6cb7a691c88b..11872be955b5 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -1,5 +1,5 @@ import z from "zod" -import path from "path" +import path from "@/util/path" import { Tool } from "./tool" import { Question } from "../question" import { Session } from "../session" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index f230cdf44cbb..9900cadb3a9e 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,6 +1,6 @@ import z from "zod" import * as fs from "fs" -import * as path from "path" +import path from "@/util/path" import { Tool } from "./tool" import { LSP } from "../lsp" import { FileTime } from "../file/time" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 7b3a45889728..593197e00012 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -15,7 +15,7 @@ import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Instance } from "../project/instance" import { Config } from "../config/config" -import path from "path" +import path from "@/util/path" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" import { Plugin } from "../plugin" diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 9536685ef94b..b7a39ea3e24e 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,4 +1,4 @@ -import path from "path" +import path from "@/util/path" import z from "zod" import { Tool } from "./tool" import { Skill } from "../skill" diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 84e799c1310e..c9795717c86c 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -1,5 +1,5 @@ import fs from "fs/promises" -import path from "path" +import path from "@/util/path" import { Global } from "../global" import { Identifier } from "../id/id" import { PermissionNext } from "../permission/next" diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index eca64d30374d..1c1e05e02615 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -1,5 +1,5 @@ import z from "zod" -import * as path from "path" +import path from "@/util/path" import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" diff --git a/packages/opencode/src/util/archive.ts b/packages/opencode/src/util/archive.ts index 34a1738a8c06..015b80520749 100644 --- a/packages/opencode/src/util/archive.ts +++ b/packages/opencode/src/util/archive.ts @@ -1,6 +1,5 @@ import { $ } from "bun" -import path from "path" - +import path from "@/util/path" export namespace Archive { export async function extractZip(zipPath: string, destDir: string) { if (process.platform === "win32") { diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 6941310bbbde..a809932b291e 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -1,4 +1,4 @@ -import path from "path" +import path from "@/util/path" import fs from "fs/promises" import { Global } from "../global" import z from "zod" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 0f2e2f4a06c8..ca18e41541ee 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,6 +1,6 @@ import { $ } from "bun" import fs from "fs/promises" -import path from "path" +import path from "@/util/path" import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Global } from "../global" diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 1cb7778623e2..eef1715906c3 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -9,7 +9,9 @@ import { afterAll } from "bun:test" const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) await fs.mkdir(dir, { recursive: true }) afterAll(() => { - fsSync.rmSync(dir, { recursive: true, force: true }) + try { + fsSync.rmSync(dir, { recursive: true, force: true }) + } catch {} }) // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 33c5e2c7397f..f5d1248e63b0 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test" -import path from "path" +import path from "../../src/util/path" 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 { tmpdir } from "../fixture/fixture" const baseCtx: Omit = { sessionID: "test", @@ -25,8 +26,9 @@ describe("tool.assertExternalDirectory", () => { }, } + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: "/tmp", + directory: tmp.path, fn: async () => { await assertExternalDirectory(ctx) }, @@ -44,10 +46,11 @@ describe("tool.assertExternalDirectory", () => { }, } + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: "/tmp/project", + directory: tmp.path, fn: async () => { - await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt")) + await assertExternalDirectory(ctx, path.join(tmp.path, "file.txt")) }, }) @@ -63,8 +66,10 @@ describe("tool.assertExternalDirectory", () => { }, } - const directory = "/tmp/project" - const target = "/tmp/outside/file.txt" + await using project = await tmpdir({ git: true }) + await using outside = await tmpdir() + const directory = project.path + const target = path.join(outside.path, "file.txt") const expected = path.join(path.dirname(target), "*") await Instance.provide({ @@ -89,8 +94,10 @@ describe("tool.assertExternalDirectory", () => { }, } - const directory = "/tmp/project" - const target = "/tmp/outside" + await using project = await tmpdir({ git: true }) + await using outside = await tmpdir() + const directory = project.path + const target = outside.path const expected = path.join(target, "*") await Instance.provide({ @@ -115,10 +122,12 @@ describe("tool.assertExternalDirectory", () => { }, } + await using project = await tmpdir({ git: true }) + await using outside = await tmpdir() await Instance.provide({ - directory: "/tmp/project", + directory: project.path, fn: async () => { - await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true }) + await assertExternalDirectory(ctx, path.join(outside.path, "file.txt"), { bypass: true }) }, }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index afa14bc6cb27..33e27c4621aa 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { toPosix } from "@opencode-ai/util/path" import { ReadTool } from "../../src/tool/read" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -73,7 +74,7 @@ describe("tool.read external_directory permission", () => { await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path))).toBe(true) + expect(extDirReq!.patterns.some((p) => p.includes(toPosix(outerTmp.path)))).toBe(true) }, }) }) @@ -349,7 +350,7 @@ describe("tool.read loaded instructions", () => { expect(result.output).toContain("system-reminder") expect(result.output).toContain("Test Instructions") expect(result.metadata.loaded).toBeDefined() - expect(result.metadata.loaded).toContain(path.join(tmp.path, "subdir", "AGENTS.md")) + expect(result.metadata.loaded).toContain(toPosix(path.join(tmp.path, "subdir", "AGENTS.md"))) }, }) }) From a74ccc06b7c879f0980f0a291a17e9c7a1f81bea Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:16:13 +1000 Subject: [PATCH 05/57] fix(windows): normalize desktop/app paths to posix --- bun.lock | 1 + packages/app/src/context/file.tsx | 6 +++--- packages/app/src/utils/worktree.ts | 8 +++++++- packages/desktop/package.json | 1 + packages/desktop/src/index.tsx | 9 ++++++--- packages/opencode/src/global/index.ts | 2 +- 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index 2b07df6ce98f..500be61d4a62 100644 --- a/bun.lock +++ b/bun.lock @@ -186,6 +186,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@opencode-ai/util": "workspace:*", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 7509334edb7b..beb0f10dc6ea 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -4,7 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import type { FileContent, FileNode } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { useParams } from "@solidjs/router" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename, toPosix } from "@opencode-ai/util/path" import { useSDK } from "./sdk" import { useSync } from "./sync" import { useLanguage } from "@/context/language" @@ -280,10 +280,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const directory = createMemo(() => sync.data.path.directory) function normalize(input: string) { - const root = directory() + const root = toPosix(directory()) const prefix = root.endsWith("/") ? root : root + "/" - let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input))) + let path = toPosix(unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))) if (path.startsWith(prefix)) { path = path.slice(prefix.length) diff --git a/packages/app/src/utils/worktree.ts b/packages/app/src/utils/worktree.ts index 581afd5535e2..dc0f035ade09 100644 --- a/packages/app/src/utils/worktree.ts +++ b/packages/app/src/utils/worktree.ts @@ -1,4 +1,10 @@ -const normalize = (directory: string) => directory.replace(/[\\/]+$/, "") +import { toPosix } from "@opencode-ai/util/path" + +const normalize = (directory: string) => { + const normalized = toPosix(directory) + if (/^[A-Z]:\/$/i.test(normalized) || normalized === "/") return normalized + return normalized.replace(/\/+$/, "") +} type State = | { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index e960d21917fd..70290d22ef54 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -15,6 +15,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@opencode-ai/util": "workspace:*", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 344c6be8d9cd..45d61f86038d 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -15,6 +15,7 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" import { Splash } from "@opencode-ai/ui/logo" import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" +import { toPosix } from "@opencode-ai/util/path" import { UPDATER_ENABLED } from "./updater" import { createMenu } from "./menu" @@ -57,7 +58,8 @@ const createPlatform = (password: Accessor): Platform => ({ multiple: opts?.multiple ?? false, title: opts?.title ?? t("desktop.dialog.chooseFolder"), }) - return result + if (Array.isArray(result)) return result.map((x) => toPosix(x)) + return result ? toPosix(result) : result }, async openFilePickerDialog(opts) { @@ -66,7 +68,8 @@ const createPlatform = (password: Accessor): Platform => ({ multiple: opts?.multiple ?? false, title: opts?.title ?? t("desktop.dialog.chooseFile"), }) - return result + if (Array.isArray(result)) return result.map((x) => toPosix(x)) + return result ? toPosix(result) : result }, async saveFilePickerDialog(opts) { @@ -74,7 +77,7 @@ const createPlatform = (password: Accessor): Platform => ({ title: opts?.title ?? t("desktop.dialog.saveFile"), defaultPath: opts?.defaultPath, }) - return result + return result ? toPosix(result) : result }, openLink(url: string) { diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index ab90542dc8da..7377898326c8 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -14,7 +14,7 @@ export namespace Global { export const Path = { // Allow override via OPENCODE_TEST_HOME for test isolation get home() { - return process.env.OPENCODE_TEST_HOME || os.homedir() + return path.toPosix(process.env.OPENCODE_TEST_HOME || os.homedir()) }, data, bin: path.join(data, "bin"), From d479bc1abe2806d2cfdb09dfd902e4c1dda4719a Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:20:56 +1000 Subject: [PATCH 06/57] test(opencode): guard against native path imports --- .../cli/cmd/tui/component/prompt/frecency.tsx | 2 +- .../cli/cmd/tui/component/prompt/history.tsx | 2 +- .../cli/cmd/tui/component/prompt/stash.tsx | 2 +- .../opencode/src/cli/cmd/tui/context/kv.tsx | 2 +- .../src/cli/cmd/tui/context/local.tsx | 2 +- .../src/cli/cmd/tui/context/theme.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- .../cli/cmd/tui/routes/session/permission.tsx | 2 +- .../cli/cmd/tui/routes/session/sidebar.tsx | 2 +- .../test/util/no-native-path-imports.test.ts | 26 +++++++++++++++++++ 10 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/test/util/no-native-path-imports.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx index 5f8a3920d533..797700008fb4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx @@ -1,4 +1,4 @@ -import path from "path" +import path from "@/util/path" import { Global } from "@/global" import { onMount } from "solid-js" import { createStore } from "solid-js/store" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx index e90503e9f52e..350e52b42b5e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -1,4 +1,4 @@ -import path from "path" +import path from "@/util/path" import { Global } from "@/global" import { onMount } from "solid-js" import { createStore, produce } from "solid-js/store" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx index fd1cba86be06..e19418d5326e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx @@ -1,4 +1,4 @@ -import path from "path" +import path from "@/util/path" import { Global } from "@/global" import { onMount } from "solid-js" import { createStore, produce } from "solid-js/store" diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx index 651c2dbc0c78..780325fb50e1 100644 --- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx @@ -2,7 +2,7 @@ import { Global } from "@/global" import { createSignal, type Setter } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -import path from "path" +import path from "@/util/path" export const { use: useKV, provider: KVProvider } = createSimpleContext({ name: "KV", diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index d058ce54fb36..5a052035c1c0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -3,7 +3,7 @@ import { batch, createEffect, createMemo } from "solid-js" import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" import { uniqueBy } from "remeda" -import path from "path" +import path from "@/util/path" import { Global } from "@/global" import { iife } from "@/util/iife" import { createSimpleContext } from "./helper" diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 7cde1b9648ee..33aeda099445 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -1,5 +1,5 @@ import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" -import path from "path" +import path from "@/util/path" import { createEffect, createMemo, onMount } from "solid-js" import { useSync } from "@tui/context/sync" import { createSimpleContext } from "./helper" 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 66dac85ecaad..d713a414ff2a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -12,7 +12,7 @@ import { useContext, } from "solid-js" import { Dynamic } from "solid-js/web" -import path from "path" +import path from "@/util/path" import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { SplitBorder } from "@tui/component/border" 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 9e79c76bf518..1ecb2243d400 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -9,7 +9,7 @@ import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useSync } from "../../context/sync" import { useTextareaKeybindings } from "../../component/textarea-keybindings" -import path from "path" +import path from "@/util/path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 4ffe91558ed7..aa754c6ee613 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -3,7 +3,7 @@ import { createMemo, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" import { Locale } from "@/util/locale" -import path from "path" +import path from "@/util/path" import type { AssistantMessage } from "@opencode-ai/sdk/v2" import { Global } from "@/global" import { Installation } from "@/installation" diff --git a/packages/opencode/test/util/no-native-path-imports.test.ts b/packages/opencode/test/util/no-native-path-imports.test.ts new file mode 100644 index 000000000000..fe0e7df19b0d --- /dev/null +++ b/packages/opencode/test/util/no-native-path-imports.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import nodePath from "node:path" + +describe("path imports", () => { + test("opencode/src uses posix path shim", async () => { + const root = nodePath.join(import.meta.dir, "..", "..", "src") + const glob = new Bun.Glob("**/*.{ts,tsx}") + const hits: string[] = [] + + for await (const file of glob.scan({ cwd: root, absolute: true })) { + const normalized = file.replace(/\\/g, "/") + if (normalized.endsWith("/src/util/path.ts")) continue + + const content = await Bun.file(file) + .text() + .catch(() => "") + if (!content) continue + + const direct = /^\s*import\s+.*\s+from\s+["'](?:node:)?path["']\s*$/m.test(content) + const named = /^\s*import\s+\{[^}]*\}\s+from\s+["'](?:node:)?path["']\s*$/m.test(content) + if (direct || named) hits.push(normalized) + } + + expect(hits).toEqual([]) + }) +}) From 24bd867f843cba22cf89bccdeed346eb77bff29e Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:39:42 +1000 Subject: [PATCH 07/57] fix(windows): normalize bash tool external directory patterns --- packages/opencode/src/tool/bash.ts | 22 +++++++++++-------- .../opencode/test/tool/bash-paths.test.ts | 18 +++++++++++++++ packages/opencode/test/tool/bash.test.ts | 2 +- 3 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/test/tool/bash-paths.test.ts diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 4ce479bde356..0e218efd37ae 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -22,6 +22,12 @@ const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 export const log = Log.create({ service: "bash-tool" }) +export function externalDirectoryGlob(target: string, kind: "file" | "directory" = "file") { + const normalized = path.toPosix(target) + const dir = kind === "directory" ? normalized : path.dirname(normalized) + return path.join(dir, "*") +} + const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset @@ -75,7 +81,7 @@ export const BashTool = Tool.define("bash", async () => { ), }), async execute(params, ctx) { - const cwd = params.workdir || Instance.directory + const cwd = path.resolve(params.workdir || Instance.directory) if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } @@ -85,7 +91,7 @@ export const BashTool = Tool.define("bash", async () => { throw new Error("Failed to parse command") } const directories = new Set() - if (!Instance.containsPath(cwd)) directories.add(cwd) + if (!Instance.containsPath(cwd)) directories.add(externalDirectoryGlob(cwd, "directory")) const patterns = new Set() const always = new Set() @@ -119,12 +125,10 @@ export const BashTool = Tool.define("bash", async () => { .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)) directories.add(normalized) + const normalized = path.toPosix(resolved) + if (Instance.containsPath(normalized)) continue + const isDir = await Filesystem.isDir(normalized) + directories.add(externalDirectoryGlob(normalized, isDir ? "directory" : "file")) } } } @@ -140,7 +144,7 @@ export const BashTool = Tool.define("bash", async () => { await ctx.ask({ permission: "external_directory", patterns: Array.from(directories), - always: Array.from(directories).map((x) => path.dirname(x) + "*"), + always: Array.from(directories), metadata: {}, }) } diff --git a/packages/opencode/test/tool/bash-paths.test.ts b/packages/opencode/test/tool/bash-paths.test.ts new file mode 100644 index 000000000000..b958b4f4bd30 --- /dev/null +++ b/packages/opencode/test/tool/bash-paths.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test" +import { externalDirectoryGlob } from "../../src/tool/bash" + +describe("tool.bash path normalization", () => { + if (process.platform !== "win32") return + + test("converts MSYS paths to drive-letter posix", () => { + expect(externalDirectoryGlob("/c/Users/Luke/file.txt")).toBe("C:/Users/Luke/*") + }) + + test("preserves UNC shares", () => { + expect(externalDirectoryGlob("\\\\server\\share\\file.txt")).toBe("//server/share/*") + }) + + test("never returns backslashes", () => { + expect(externalDirectoryGlob("C:\\Users\\Luke\\file.txt")).not.toContain("\\") + }) +}) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 454293c8fba8..d6fb4e926c5e 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -144,7 +144,7 @@ describe("tool.bash permissions", () => { ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain("/tmp") + expect(extDirReq!.patterns).toContain("/tmp/*") }, }) }) From d1f13d9b5dfb3c8659ed5e934f051648e0b7680d Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:42:33 +1000 Subject: [PATCH 08/57] fix(windows): canonicalize watcher and permission home paths --- packages/opencode/src/file/watcher.ts | 7 ++++--- packages/opencode/src/permission/next.ts | 10 ++++++---- packages/opencode/test/permission/next.test.ts | 17 +++++++++++------ 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 70231ec1076f..aadb6fa91c7c 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -66,9 +66,10 @@ export namespace FileWatcher { const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => { if (err) return for (const evt of evts) { - if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) + const file = path.toPosix(evt.path) + if (evt.type === "create") Bus.publish(Event.Updated, { file, event: "add" }) + if (evt.type === "update") Bus.publish(Event.Updated, { file, event: "change" }) + if (evt.type === "delete") Bus.publish(Event.Updated, { file, event: "unlink" }) } } diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 2481f104ed15..a2b68df4208d 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -8,16 +8,18 @@ import { fn } from "@/util/fn" import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" import os from "os" +import { toPosix } from "@/util/path" import z from "zod" export namespace PermissionNext { const log = Log.create({ service: "permission" }) function expand(pattern: string): string { - if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) - if (pattern === "~") return os.homedir() - if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5) - if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5) + const home = toPosix(os.homedir()) + if (pattern.startsWith("~/")) return home + pattern.slice(1) + if (pattern === "~") return home + if (pattern.startsWith("$HOME/")) return home + pattern.slice(5) + if (pattern.startsWith("$HOME")) return home + pattern.slice(5) return pattern } diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 29f1efa40195..3a5c36d01a51 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "bun:test" import os from "os" +import { toPosix } from "@opencode-ai/util/path" import { PermissionNext } from "../../src/permission/next" import { Instance } from "../../src/project/instance" import { Storage } from "../../src/storage/storage" @@ -41,17 +42,21 @@ test("fromConfig - empty object", () => { test("fromConfig - expands tilde to home directory", () => { const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) - expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) + expect(result).toEqual([ + { permission: "external_directory", pattern: `${toPosix(os.homedir())}/projects/*`, action: "allow" }, + ]) }) test("fromConfig - expands $HOME to home directory", () => { const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) - expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) + expect(result).toEqual([ + { permission: "external_directory", pattern: `${toPosix(os.homedir())}/projects/*`, action: "allow" }, + ]) }) test("fromConfig - expands $HOME without trailing slash", () => { const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } }) - expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) + expect(result).toEqual([{ permission: "external_directory", pattern: toPosix(os.homedir()), action: "allow" }]) }) test("fromConfig - does not expand tilde in middle of path", () => { @@ -61,18 +66,18 @@ test("fromConfig - does not expand tilde in middle of path", () => { test("fromConfig - expands exact tilde to home directory", () => { const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } }) - expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) + expect(result).toEqual([{ permission: "external_directory", pattern: toPosix(os.homedir()), action: "allow" }]) }) test("evaluate - matches expanded tilde pattern", () => { const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) - const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) + const result = PermissionNext.evaluate("external_directory", `${toPosix(os.homedir())}/projects/file.txt`, ruleset) expect(result.action).toBe("allow") }) test("evaluate - matches expanded $HOME pattern", () => { const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) - const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) + const result = PermissionNext.evaluate("external_directory", `${toPosix(os.homedir())}/projects/file.txt`, ruleset) expect(result.action).toBe("allow") }) From a3313b193f9252f4d2dff9503e10ede77e124b4b Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:51:33 +1000 Subject: [PATCH 09/57] fix(windows): prefer Git Bash for BashTool --- packages/opencode/src/shell/shell.ts | 14 ++++++++++---- packages/opencode/test/tool/bash.test.ts | 6 ++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index bb84c7447ea4..b3ac2fdbf51e 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -40,10 +40,16 @@ export namespace Shell { if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH const git = Bun.which("git") if (git) { - // git.exe is typically at: C:\Program Files\Git\cmd\git.exe - // bash.exe is at: C:\Program Files\Git\bin\bash.exe - const bash = path.join(git, "..", "..", "bin", "bash.exe") - if (Bun.file(bash).size) return bash + const base = path.dirname(git) + for (let i = 0; i < 6; i++) { + const current = path.resolve(base, ...Array(i).fill("..")) + + const bin = path.join(current, "bin", "bash.exe") + if (Bun.file(bin).size) return bin + + const usr = path.join(current, "usr", "bin", "bash.exe") + if (Bun.file(usr).size) return usr + } } return process.env.COMSPEC || "cmd.exe" } diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index d6fb4e926c5e..dd7ba66b8a67 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test" +import os from "os" import path from "path" +import { toPosix } from "@opencode-ai/util/path" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -137,14 +139,14 @@ describe("tool.bash permissions", () => { await bash.execute( { command: "ls", - workdir: "/tmp", + workdir: os.tmpdir(), description: "List /tmp", }, testCtx, ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain("/tmp/*") + expect(extDirReq!.patterns).toContain(`${toPosix(os.tmpdir())}/*`) }, }) }) From a5e0c2ae74833d7118f125e9c36b94f09010afa2 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:57:13 +1000 Subject: [PATCH 10/57] fix(windows): remove path.sep assumptions in tui/storage --- .../src/cli/cmd/tui/component/prompt/autocomplete.tsx | 5 ++++- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 2 +- .../opencode/src/cli/cmd/tui/routes/session/permission.tsx | 6 +++--- packages/opencode/src/storage/storage.ts | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 718929d445b3..b3df0dd3836c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -12,6 +12,8 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" +import path from "@/util/path" +import { pathToFileURL } from "url" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -236,7 +238,8 @@ export function Autocomplete(props: { const width = props.anchor().width - 4 options.push( ...sortedFiles.map((item): AutocompleteOption => { - let url = `file://${process.cwd()}/${item}` + const filepath = path.join(path.resolve("."), item) + let url = pathToFileURL(filepath).toString() let filename = item if (lineRange && !item.endsWith("/")) { filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` 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 d713a414ff2a..b8d3c1ecd0d7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1613,7 +1613,7 @@ function Bash(props: ToolProps) { const home = Global.Path.home if (!home) return absolute - const match = absolute === home || absolute.startsWith(home + path.sep) + const match = absolute === home || absolute.startsWith(home + "/") return match ? absolute.replace(home, "~") : absolute }) 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 1ecb2243d400..9206607122c0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -21,16 +21,16 @@ type PermissionStage = "permission" | "always" | "reject" function normalizePath(input?: string) { if (!input) return "" - const cwd = process.cwd() + const cwd = path.resolve(".") const home = Global.Path.home - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) + const absolute = path.isAbsolute(input) ? path.normalize(input) : path.resolve(cwd, input) const relative = path.relative(cwd, absolute) if (!relative) return "." 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 diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index b9e6d454753e..2c1cb6a20ff9 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -217,7 +217,7 @@ export namespace Storage { cwd: path.join(dir, ...prefix), onlyFiles: true, }), - ).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)])) + ).then((results) => results.map((x) => [...prefix, ...path.toPosix(x).slice(0, -5).split("/")])) result.sort() return result } catch { From 2b0faa2aa1d716d80cff7ac9407a2f717c400d71 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:58:32 +1000 Subject: [PATCH 11/57] fix(windows): set ripgrep path separator to '/' --- packages/opencode/src/file/ripgrep.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index d332bd8ed537..556aee75b730 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -208,6 +208,7 @@ export namespace Ripgrep { maxDepth?: number }) { const args = [await filepath(), "--files", "--glob=!.git/*"] + if (process.platform === "win32") args.push("--path-separator=/") if (input.follow !== false) args.push("--follow") if (input.hidden !== false) args.push("--hidden") if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) @@ -370,6 +371,7 @@ export namespace Ripgrep { follow?: boolean }) { const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] + if (process.platform === "win32") args.push("--path-separator=/") if (input.follow !== false) args.push("--follow") if (input.glob) { From 4bad0bbac693cd55c4eea55da1ce6de8d21f79b5 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:02:13 +1000 Subject: [PATCH 12/57] fix(windows): trim UNC root trailing slash --- packages/opencode/test/util/path.test.ts | 8 ++++++++ packages/util/src/path.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/util/path.test.ts b/packages/opencode/test/util/path.test.ts index 0bb38f22239c..219d90ea62f6 100644 --- a/packages/opencode/test/util/path.test.ts +++ b/packages/opencode/test/util/path.test.ts @@ -27,6 +27,14 @@ describe("util.path", () => { expect(path.toPosix("/mnt/f/dev")).toBe("F:/dev") }) + test("normalize() converts MSYS roots", () => { + expect(path.normalize("/c/Users/Luke")).toBe("C:/Users/Luke") + }) + + test("normalize() preserves UNC roots", () => { + expect(path.normalize("\\\\server\\share")).toBe("//server/share") + }) + test("preserves UNC paths", () => { const unc = "\\\\server\\share\\file.txt" expect(path.toPosix(unc)).toBe("//server/share/file.txt") diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index 66399bccffc4..d5eaddc5f17f 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -21,7 +21,9 @@ export function toPosix(p: string) { const slashed = p.replace(/\\/g, "/") const msys = slashed.replace(/^\/(?:cygdrive\/|mnt\/)?([a-zA-Z])(?:\/|$)/, (_, d) => `${d.toUpperCase()}:/`) - return msys.replace(/^([a-z]):\//, (_, d) => `${d.toUpperCase()}:/`) + const res = msys.replace(/^([a-z]):\//, (_, d) => `${d.toUpperCase()}:/`) + if (/^\/\/[^/]+\/[^/]+\/$/.test(res)) return res.slice(0, -1) + return res } export function getDirectory(path: string | undefined) { From 319874b0d5c50344279b441a970256c1f762e9fc Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:04:07 +1000 Subject: [PATCH 13/57] test: extend native path import guard to app/desktop --- .../test/util/no-native-path-imports.test.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/opencode/test/util/no-native-path-imports.test.ts b/packages/opencode/test/util/no-native-path-imports.test.ts index fe0e7df19b0d..320e4c3db374 100644 --- a/packages/opencode/test/util/no-native-path-imports.test.ts +++ b/packages/opencode/test/util/no-native-path-imports.test.ts @@ -2,23 +2,30 @@ import { describe, expect, test } from "bun:test" import nodePath from "node:path" describe("path imports", () => { - test("opencode/src uses posix path shim", async () => { - const root = nodePath.join(import.meta.dir, "..", "..", "src") + test("repo source avoids native path imports", async () => { + const roots = [ + { name: "opencode", dir: nodePath.join(import.meta.dir, "..", "..", "src"), allow: ["/src/util/path.ts"] }, + { name: "app", dir: nodePath.join(import.meta.dir, "..", "..", "..", "app", "src"), allow: [] }, + { name: "desktop", dir: nodePath.join(import.meta.dir, "..", "..", "..", "desktop", "src"), allow: [] }, + ] const glob = new Bun.Glob("**/*.{ts,tsx}") const hits: string[] = [] - for await (const file of glob.scan({ cwd: root, absolute: true })) { - const normalized = file.replace(/\\/g, "/") - if (normalized.endsWith("/src/util/path.ts")) continue + for (const root of roots) { + const allow = new Set(root.allow) + for await (const file of glob.scan({ cwd: root.dir, absolute: true })) { + const normalized = file.replace(/\\/g, "/") + if (allow.size && Array.from(allow).some((x) => normalized.endsWith(x))) continue - const content = await Bun.file(file) - .text() - .catch(() => "") - if (!content) continue + const content = await Bun.file(file) + .text() + .catch(() => "") + if (!content) continue - const direct = /^\s*import\s+.*\s+from\s+["'](?:node:)?path["']\s*$/m.test(content) - const named = /^\s*import\s+\{[^}]*\}\s+from\s+["'](?:node:)?path["']\s*$/m.test(content) - if (direct || named) hits.push(normalized) + const direct = /^\s*import\s+.*\s+from\s+["'](?:node:)?path["']\s*$/m.test(content) + const named = /^\s*import\s+\{[^}]*\}\s+from\s+["'](?:node:)?path["']\s*$/m.test(content) + if (direct || named) hits.push(`${root.name}:${normalized}`) + } } expect(hits).toEqual([]) From 6c78208fa7025e0d3914a1c3f90b9d2703b46c00 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:06:32 +1000 Subject: [PATCH 14/57] fix(windows): normalize configured path permission patterns --- packages/opencode/src/permission/next.ts | 8 +++++++- packages/opencode/test/permission/next.test.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index a2b68df4208d..39261fd41583 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -14,6 +14,8 @@ import z from "zod" export namespace PermissionNext { const log = Log.create({ service: "permission" }) + const PATH_PERMISSIONS = new Set(["edit", "read", "list", "glob", "grep", "lsp", "external_directory"]) + function expand(pattern: string): string { const home = toPosix(os.homedir()) if (pattern.startsWith("~/")) return home + pattern.slice(1) @@ -56,7 +58,11 @@ export namespace PermissionNext { continue } ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), + ...Object.entries(value).map(([pattern, action]) => { + const expanded = expand(pattern) + const normalized = PATH_PERMISSIONS.has(key) ? toPosix(expanded) : expanded + return { permission: key, pattern: normalized, action } + }), ) } return ruleset diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 3a5c36d01a51..5bb50b578396 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,6 +6,8 @@ import { Instance } from "../../src/project/instance" import { Storage } from "../../src/storage/storage" import { tmpdir } from "../fixture/fixture" +const isWin = process.platform === "win32" + // fromConfig tests test("fromConfig - string value becomes wildcard rule", () => { @@ -69,6 +71,18 @@ test("fromConfig - expands exact tilde to home directory", () => { expect(result).toEqual([{ permission: "external_directory", pattern: toPosix(os.homedir()), action: "allow" }]) }) +if (isWin) { + test("fromConfig - normalizes windows-style backslashes for path permissions", () => { + const result = PermissionNext.fromConfig({ external_directory: { "C:\\Users\\Luke\\*": "allow" } }) + expect(result).toEqual([{ permission: "external_directory", pattern: "C:/Users/Luke/*", action: "allow" }]) + }) + + test("fromConfig - normalizes MSYS roots for path permissions", () => { + const result = PermissionNext.fromConfig({ external_directory: { "/c/Users/Luke/*": "allow" } }) + expect(result).toEqual([{ permission: "external_directory", pattern: "C:/Users/Luke/*", action: "allow" }]) + }) +} + test("evaluate - matches expanded tilde pattern", () => { const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) const result = PermissionNext.evaluate("external_directory", `${toPosix(os.homedir())}/projects/file.txt`, ruleset) From faca88830b4cc9a2379b3930857c50bffee726e1 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:20:50 +1000 Subject: [PATCH 15/57] fix(windows): normalize ReadTool absolute paths --- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/test/tool/read.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 9900cadb3a9e..397da9bb49a6 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -22,7 +22,7 @@ export const ReadTool = Tool.define("read", { limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(), }), async execute(params, ctx) { - let filepath = params.filePath + let filepath = path.toPosix(params.filePath) if (!path.isAbsolute(filepath)) { filepath = path.resolve(Instance.directory, filepath) } diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 33e27c4621aa..6bc89462cbdd 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -7,6 +7,8 @@ import { tmpdir } from "../fixture/fixture" import { PermissionNext } from "../../src/permission/next" import { Agent } from "../../src/agent/agent" +const isWin = process.platform === "win32" + const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ctx = { @@ -124,6 +126,26 @@ describe("tool.read external_directory permission", () => { }, }) }) + + if (!isWin) return + + test("windows: reads MSYS-style absolute paths", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "msys.txt"), "msys content") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const msysDir = toPosix(tmp.path).replace(/^([a-zA-Z]):\//, (_, d) => `/${d.toLowerCase()}/`) + const result = await read.execute({ filePath: `${msysDir}/msys.txt` }, ctx) + expect(result.output).toContain("msys content") + }, + }) + }) }) describe("tool.read env file permissions", () => { From 9b61da7dbf983e0f459f337b68addd4bd47eba1b Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:23:40 +1000 Subject: [PATCH 16/57] fix(windows): normalize WriteTool file paths --- packages/opencode/src/tool/write.ts | 3 +- .../opencode/test/tool/write-paths.test.ts | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/tool/write-paths.test.ts diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 1c1e05e02615..093872733488 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 input = path.toPosix(params.filePath) + const filepath = path.isAbsolute(input) ? input : path.resolve(Instance.directory, input) await assertExternalDirectory(ctx, filepath) const file = Bun.file(filepath) diff --git a/packages/opencode/test/tool/write-paths.test.ts b/packages/opencode/test/tool/write-paths.test.ts new file mode 100644 index 000000000000..d8866d1c8582 --- /dev/null +++ b/packages/opencode/test/tool/write-paths.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { toPosix } from "@opencode-ai/util/path" +import { WriteTool } from "../../src/tool/write" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import type { PermissionNext } from "../../src/permission/next" + +const isWin = process.platform === "win32" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.write path normalization", () => { + if (!isWin) return + + test("windows: accepts MSYS-style absolute file paths", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + lsp: false, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const write = await WriteTool.init() + + const msysDir = toPosix(tmp.path).replace(/^([a-zA-Z]):\//, (_, d) => `/${d.toLowerCase()}/`) + const inputPath = `${msysDir}/out.txt` + const expected = toPosix(path.join(tmp.path, "out.txt")) + + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + + const result = await write.execute({ filePath: inputPath, content: "hi" }, testCtx) + + expect(result.metadata.filepath).toBe(expected) + expect(result.metadata.filepath).not.toContain("\\") + expect(await Bun.file(expected).text()).toBe("hi") + + const editReq = requests.find((r) => r.permission === "edit") + expect(editReq).toBeDefined() + expect(editReq!.metadata.filepath).toBe(expected) + }, + }) + }) +}) From 647c9e0421cf359603ce0ff9bba84b9654d8c592 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:26:56 +1000 Subject: [PATCH 17/57] fix(windows): normalize EditTool file paths --- packages/opencode/src/tool/edit.ts | 3 +- .../opencode/test/tool/edit-paths.test.ts | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/tool/edit-paths.test.ts diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index c3cebcb76915..5e2ff8e00ef5 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("oldString and newString must be different") } - const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) + const input = path.toPosix(params.filePath) + const filePath = path.isAbsolute(input) ? input : path.resolve(Instance.directory, input) await assertExternalDirectory(ctx, filePath) let diff = "" diff --git a/packages/opencode/test/tool/edit-paths.test.ts b/packages/opencode/test/tool/edit-paths.test.ts new file mode 100644 index 000000000000..18ab4a5292d9 --- /dev/null +++ b/packages/opencode/test/tool/edit-paths.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { toPosix } from "@opencode-ai/util/path" +import { EditTool } from "../../src/tool/edit" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import type { PermissionNext } from "../../src/permission/next" +import { FileTime } from "../../src/file/time" + +const isWin = process.platform === "win32" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.edit path normalization", () => { + if (!isWin) return + + test("windows: accepts MSYS-style absolute file paths", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + lsp: false, + }, + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "hello") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await EditTool.init() + + const msysDir = toPosix(tmp.path).replace(/^([a-zA-Z]):\//, (_, d) => `/${d.toLowerCase()}/`) + const inputPath = `${msysDir}/a.txt` + const expected = toPosix(path.join(tmp.path, "a.txt")) + + FileTime.read(ctx.sessionID, expected) + + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + + const result = await edit.execute( + { + filePath: inputPath, + oldString: "hello", + newString: "world", + }, + testCtx, + ) + + expect(await Bun.file(expected).text()).toBe("world") + expect(result.metadata.filediff.file).toBe(expected) + expect(result.metadata.filediff.file).not.toContain("\\") + + const editReq = requests.find((r) => r.permission === "edit") + expect(editReq).toBeDefined() + expect(editReq!.metadata.filepath).toBe(expected) + }, + }) + }) +}) From 9129db6f5b477923a9a38ce608bd7bacae8da7fe Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:31:24 +1000 Subject: [PATCH 18/57] fix(windows): normalize Filesystem.globUp outputs --- packages/opencode/src/util/filesystem.ts | 7 ++++--- .../opencode/test/util/filesystem.test.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 118ddd2d221c..5e45a57dc2a9 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -95,7 +95,8 @@ export namespace Filesystem { } export async function globUp(pattern: string, start: string, stop?: string) { - let current = start + let current = toPosix(start) + const ceiling = stop ? toPosix(stop) : undefined const result = [] while (true) { try { @@ -107,12 +108,12 @@ export namespace Filesystem { followSymlinks: true, dot: true, })) { - result.push(match) + result.push(toPosix(match)) } } catch { // Skip invalid glob patterns } - if (stop === current) break + if (ceiling === current) break const parent = path.dirname(current) if (parent === current) break current = parent diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 0e5f0ba381d0..a581e4d328e9 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -3,6 +3,9 @@ import os from "node:os" import path from "node:path" import { mkdtemp, mkdir, rm } from "node:fs/promises" import { Filesystem } from "../../src/util/filesystem" +import { toPosix } from "@opencode-ai/util/path" + +const isWin = process.platform === "win32" describe("util.filesystem", () => { test("exists() is true for files and directories", async () => { @@ -36,4 +39,22 @@ describe("util.filesystem", () => { await rm(tmp, { recursive: true, force: true }) }) + + if (!isWin) return + + test("globUp() returns posix absolute paths on Windows", async () => { + const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-globup-")) + const root = toPosix(tmp) + const file = toPosix(path.join(tmp, "foo.txt")) + const nested = toPosix(path.join(tmp, "sub", "nested")) + + await mkdir(path.join(tmp, "sub", "nested"), { recursive: true }) + await Bun.write(file, "hello") + + const matches = await Filesystem.globUp("*.txt", nested) + expect(matches.some((x) => x.endsWith("/foo.txt"))).toBe(true) + expect(matches.some((x) => x.includes("\\"))).toBe(false) + + await rm(root, { recursive: true, force: true }) + }) }) From 4a765bea2ab3bfcb236aad5e90b003f809e060f8 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:53:55 +1000 Subject: [PATCH 19/57] fix(windows): normalize glob/grep tool paths --- packages/opencode/src/tool/glob.ts | 3 +- packages/opencode/src/tool/grep.ts | 12 +++-- .../opencode/test/tool/glob-paths.test.ts | 46 +++++++++++++++++++ .../opencode/test/tool/grep-paths.test.ts | 45 ++++++++++++++++++ 4 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/test/tool/glob-paths.test.ts create mode 100644 packages/opencode/test/tool/grep-paths.test.ts diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 601e5c0f9e28..3311477245b8 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -28,8 +28,7 @@ export const GlobTool = Tool.define("glob", { }, }) - let search = params.path ?? Instance.directory - search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + const search = path.resolve(Instance.directory, path.toPosix(params.path ?? Instance.directory)) await assertExternalDirectory(ctx, search, { kind: "directory" }) const limit = 100 diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index aac34167f8ae..ead7d2e09083 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -32,8 +32,7 @@ export const GrepTool = Tool.define("grep", { }, }) - let searchPath = params.path ?? Instance.directory - searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) + const searchPath = path.resolve(Instance.directory, path.toPosix(params.path ?? Instance.directory)) await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) const rgPath = await Ripgrep.filepath() @@ -46,6 +45,9 @@ export const GrepTool = Tool.define("grep", { "--regexp", params.pattern, ] + if (process.platform === "win32") { + args.push("--path-separator=/") + } if (params.include) { args.push("--glob", params.include) } @@ -84,8 +86,10 @@ export const GrepTool = Tool.define("grep", { for (const line of lines) { if (!line) continue - const [filePath, lineNumStr, ...lineTextParts] = line.split("|") - if (!filePath || !lineNumStr || lineTextParts.length === 0) continue + const [rawFilePath, lineNumStr, ...lineTextParts] = line.split("|") + if (!rawFilePath || !lineNumStr || lineTextParts.length === 0) continue + + const filePath = path.toPosix(rawFilePath) const lineNum = parseInt(lineNumStr, 10) const lineText = lineTextParts.join("|") diff --git a/packages/opencode/test/tool/glob-paths.test.ts b/packages/opencode/test/tool/glob-paths.test.ts new file mode 100644 index 000000000000..1c93ffef569c --- /dev/null +++ b/packages/opencode/test/tool/glob-paths.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import nodePath from "node:path" +import { toPosix } from "@opencode-ai/util/path" +import { GlobTool } from "../../src/tool/glob" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +const isWin = process.platform === "win32" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.glob path normalization", () => { + if (!isWin) return + + test("windows: accepts MSYS-style absolute search paths", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(nodePath.join(dir, "a.ts"), "export const x = 1\n") + await Bun.write(nodePath.join(dir, "b.js"), "console.log('x')\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + const msysDir = toPosix(tmp.path).replace(/^([a-zA-Z]):\//, (_, d) => `/${d.toLowerCase()}/`) + const result = await glob.execute({ pattern: "*.ts", path: msysDir }, ctx) + + const expected = toPosix(nodePath.join(tmp.path, "a.ts")) + expect(result.output).toContain(expected) + expect(result.output).not.toContain("\\") + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/grep-paths.test.ts b/packages/opencode/test/tool/grep-paths.test.ts new file mode 100644 index 000000000000..cd8dc859ceaf --- /dev/null +++ b/packages/opencode/test/tool/grep-paths.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import nodePath from "node:path" +import { toPosix } from "@opencode-ai/util/path" +import { GrepTool } from "../../src/tool/grep" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +const isWin = process.platform === "win32" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.grep path normalization", () => { + if (!isWin) return + + test("windows: accepts MSYS-style absolute search paths", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(nodePath.join(dir, "a.txt"), "hello\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const grep = await GrepTool.init() + const msysDir = toPosix(tmp.path).replace(/^([a-zA-Z]):\//, (_, d) => `/${d.toLowerCase()}/`) + const result = await grep.execute({ pattern: "hello", path: msysDir }, ctx) + + const expected = toPosix(nodePath.join(tmp.path, "a.txt")) + expect(result.output).toContain(expected) + expect(result.output).not.toContain("\\") + }, + }) + }) +}) From 8adae665cfaaeaec220cd11f5344d8ae47b43781 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:55:53 +1000 Subject: [PATCH 20/57] fix(windows): normalize LspTool file paths --- packages/opencode/src/tool/lsp.ts | 3 +- packages/opencode/test/tool/lsp-paths.test.ts | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/tool/lsp-paths.test.ts diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 06b9654b85f6..9f9cddd8701d 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -28,7 +28,8 @@ 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 input = path.toPosix(args.filePath) + const file = path.isAbsolute(input) ? input : path.resolve(Instance.directory, input) await assertExternalDirectory(ctx, file) await ctx.ask({ diff --git a/packages/opencode/test/tool/lsp-paths.test.ts b/packages/opencode/test/tool/lsp-paths.test.ts new file mode 100644 index 000000000000..042d6ef545bc --- /dev/null +++ b/packages/opencode/test/tool/lsp-paths.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import nodePath from "node:path" +import { toPosix } from "@opencode-ai/util/path" +import { LspTool } from "../../src/tool/lsp" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +const isWin = process.platform === "win32" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.lsp path normalization", () => { + if (!isWin) return + + test("windows: accepts MSYS-style absolute file paths", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + lsp: false, + }, + init: async (dir) => { + await Bun.write(nodePath.join(dir, "a.ts"), "export const x = 1\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const lsp = await LspTool.init() + const msysDir = toPosix(tmp.path).replace(/^([a-zA-Z]):\//, (_, d) => `/${d.toLowerCase()}/`) + const inputPath = `${msysDir}/a.ts` + + await expect( + lsp.execute({ operation: "hover", filePath: inputPath, line: 1, character: 1 }, ctx), + ).rejects.toThrow("No LSP server available") + }, + }) + }) +}) From b19ed8397a6b12705cad2b7a69a4b09c2d93ffac Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:59:03 +1000 Subject: [PATCH 21/57] fix(windows): normalize instruction paths --- packages/opencode/src/session/instruction.ts | 1 + packages/opencode/test/session/instruction.test.ts | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index f08d4f646af5..fdfcdeb8cdb7 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -94,6 +94,7 @@ export namespace InstructionPrompt { if (instruction.startsWith("~/")) { instruction = path.join(os.homedir(), instruction.slice(2)) } + instruction = path.toPosix(instruction) const matches = path.isAbsolute(instruction) ? await Array.fromAsync( new Bun.Glob(path.basename(instruction)).scan({ diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 67719fa339c8..affef4bc2caa 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { toPosix } from "@opencode-ai/util/path" import { InstructionPrompt } from "../../src/session/instruction" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -16,7 +17,7 @@ describe("InstructionPrompt.resolve", () => { directory: tmp.path, fn: async () => { const system = await InstructionPrompt.systemPaths() - expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true) + expect(system.has(toPosix(path.join(tmp.path, "AGENTS.md")))).toBe(true) const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"), "test-message-1") expect(results).toEqual([]) @@ -35,7 +36,7 @@ describe("InstructionPrompt.resolve", () => { directory: tmp.path, fn: async () => { const system = await InstructionPrompt.systemPaths() - expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false) + expect(system.has(toPosix(path.join(tmp.path, "subdir", "AGENTS.md")))).toBe(false) const results = await InstructionPrompt.resolve( [], @@ -43,7 +44,7 @@ describe("InstructionPrompt.resolve", () => { "test-message-2", ) expect(results.length).toBe(1) - expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) + expect(results[0].filepath).toBe(toPosix(path.join(tmp.path, "subdir", "AGENTS.md"))) }, }) }) From e35fd1c66f4bb163f481fc9abdbcea6612ad6302 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:02:20 +1000 Subject: [PATCH 22/57] fix(windows): normalize external_directory targets --- .../opencode/src/tool/external-directory.ts | 8 +++-- .../test/tool/external-directory.test.ts | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index e32cae2d7dfe..31e0effb88f0 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -14,10 +14,12 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string if (options?.bypass) return - if (Instance.containsPath(target)) return + const filepath = path.toPosix(target) + + if (Instance.containsPath(filepath)) return const kind = options?.kind ?? "file" - const parentDir = kind === "directory" ? target : path.dirname(target) + const parentDir = kind === "directory" ? filepath : path.dirname(filepath) const glob = path.join(parentDir, "*") await ctx.ask({ @@ -25,7 +27,7 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string patterns: [glob], always: [glob], metadata: { - filepath: target, + filepath, parentDir, }, }) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index f5d1248e63b0..ed51333a4368 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -5,6 +5,9 @@ import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" import type { PermissionNext } from "../../src/permission/next" import { tmpdir } from "../fixture/fixture" +import { toPosix } from "@opencode-ai/util/path" + +const isWin = process.platform === "win32" const baseCtx: Omit = { sessionID: "test", @@ -133,4 +136,33 @@ describe("tool.assertExternalDirectory", () => { expect(requests.length).toBe(0) }) + + if (!isWin) return + + test("windows: normalizes MSYS-style absolute targets", async () => { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + await using project = await tmpdir({ git: true }) + await using outside = await tmpdir() + + const msysDir = toPosix(outside.path).replace(/^([a-zA-Z]):\//, (_, d) => `/${d.toLowerCase()}/`) + await Instance.provide({ + directory: project.path, + fn: async () => { + await assertExternalDirectory(ctx, `${msysDir}/file.txt`) + }, + }) + + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns[0]).toContain(toPosix(outside.path)) + expect(req!.patterns[0]).not.toContain("\\") + expect(req!.patterns[0]).not.toContain("/c/") + }) }) From f789c95b6083c128efa5607c3edd89ef5fba4740 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:07:05 +1000 Subject: [PATCH 23/57] fix(windows): normalize discovered skill locations --- packages/opencode/src/skill/skill.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 50986a2db00b..6816dd5e0550 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -45,12 +45,14 @@ export namespace Skill { const skills: Record = {} const addSkill = async (match: string) => { - const md = await ConfigMarkdown.parse(match).catch((err) => { + const file = path.toPosix(match) + + const md = await ConfigMarkdown.parse(file).catch((err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message - : `Failed to parse skill ${match}` + : `Failed to parse skill ${file}` Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load skill", { skill: match, err }) + log.error("failed to load skill", { skill: file, err }) return undefined }) @@ -64,14 +66,14 @@ export namespace Skill { log.warn("duplicate skill name", { name: parsed.data.name, existing: skills[parsed.data.name].location, - duplicate: match, + duplicate: file, }) } skills[parsed.data.name] = { name: parsed.data.name, description: parsed.data.description, - location: match, + location: file, } } From b8e95d6900de9c88795938254c1cdf5486338095 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:08:48 +1000 Subject: [PATCH 24/57] fix(windows): normalize session.list directory filter --- packages/opencode/src/server/routes/session.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 3850376bdb40..991208bbd8a5 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 path from "@/util/path" const log = Log.create({ service: "server" }) @@ -54,9 +55,15 @@ export const SessionRoutes = lazy(() => async (c) => { const query = c.req.valid("query") const term = query.search?.toLowerCase() + const directory = query.directory ? path.normalize(path.toPosix(query.directory)) : undefined + const directoryKey = directory && process.platform === "win32" ? directory.toLowerCase() : directory const sessions: Session.Info[] = [] for await (const session of Session.list()) { - if (query.directory !== undefined && session.directory !== query.directory) continue + if (directoryKey !== undefined) { + const sessionDir = path.normalize(path.toPosix(session.directory)) + const sessionKey = process.platform === "win32" ? sessionDir.toLowerCase() : sessionDir + if (sessionKey !== directoryKey) 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 From 6c72a1440e4225910c03a5d1acb57d3b5b17966d Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:12:13 +1000 Subject: [PATCH 25/57] fix(windows): parse apply_patch headers with drive letters --- packages/opencode/src/patch/index.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index d9d9b344c8cc..a3135338b4ee 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -78,24 +78,29 @@ export namespace Patch { ): { filePath: string; movePath?: string; nextIdx: number } | null { const line = lines[startIdx] - if (line.startsWith("*** Add File:")) { - const filePath = line.split(":", 2)[1]?.trim() + const addPrefix = "*** Add File:" + const deletePrefix = "*** Delete File:" + const updatePrefix = "*** Update File:" + const movePrefix = "*** Move to:" + + if (line.startsWith(addPrefix)) { + const filePath = line.slice(addPrefix.length).trim() return filePath ? { filePath, nextIdx: startIdx + 1 } : null } - if (line.startsWith("*** Delete File:")) { - const filePath = line.split(":", 2)[1]?.trim() + if (line.startsWith(deletePrefix)) { + const filePath = line.slice(deletePrefix.length).trim() return filePath ? { filePath, nextIdx: startIdx + 1 } : null } - if (line.startsWith("*** Update File:")) { - const filePath = line.split(":", 2)[1]?.trim() + if (line.startsWith(updatePrefix)) { + const filePath = line.slice(updatePrefix.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() + if (nextIdx < lines.length && lines[nextIdx].startsWith(movePrefix)) { + movePath = lines[nextIdx].slice(movePrefix.length).trim() nextIdx++ } From 86795e67584e9fb2af468c558f7422df07e85369 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:20:31 +1000 Subject: [PATCH 26/57] fix(windows): normalize config path scans and plugin resolution --- packages/opencode/src/config/config.ts | 38 ++++++++++++-------- packages/opencode/test/config/config.test.ts | 4 +-- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7932b3614ca1..565133bf05a1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -246,19 +246,21 @@ export namespace Config { dot: true, cwd: dir, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { + const filepath = path.toPosix(item) + + const md = await ConfigMarkdown.parse(filepath).catch(async (err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message - : `Failed to parse command ${item}` + : `Failed to parse command ${filepath}` const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load command", { command: item, err }) + log.error("failed to load command", { command: filepath, err }) return undefined }) if (!md) continue const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] - const file = rel(item, patterns) ?? path.basename(item) + const file = rel(filepath, patterns) ?? path.basename(filepath) const name = trim(file) const config = { @@ -271,7 +273,7 @@ export namespace Config { result[config.name] = parsed.data continue } - throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) + throw new InvalidError({ path: filepath, issues: parsed.error.issues }, { cause: parsed.error }) } return result } @@ -286,19 +288,21 @@ export namespace Config { dot: true, cwd: dir, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { + const filepath = path.toPosix(item) + + const md = await ConfigMarkdown.parse(filepath).catch(async (err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message - : `Failed to parse agent ${item}` + : `Failed to parse agent ${filepath}` const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load agent", { agent: item, err }) + log.error("failed to load agent", { agent: filepath, err }) return undefined }) if (!md) continue const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] - const file = rel(item, patterns) ?? path.basename(item) + const file = rel(filepath, patterns) ?? path.basename(filepath) const agentName = trim(file) const config = { @@ -311,7 +315,7 @@ export namespace Config { result[config.name] = parsed.data continue } - throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) + throw new InvalidError({ path: filepath, issues: parsed.error.issues }, { cause: parsed.error }) } return result } @@ -325,19 +329,21 @@ export namespace Config { dot: true, cwd: dir, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { + const filepath = path.toPosix(item) + + const md = await ConfigMarkdown.parse(filepath).catch(async (err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message - : `Failed to parse mode ${item}` + : `Failed to parse mode ${filepath}` const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load mode", { mode: item, err }) + log.error("failed to load mode", { mode: filepath, err }) return undefined }) if (!md) continue const config = { - name: path.basename(item, ".md"), + name: path.basename(filepath, ".md"), ...md.data, prompt: md.content.trim(), } @@ -1217,7 +1223,9 @@ export namespace Config { for (let i = 0; i < data.plugin.length; i++) { const plugin = data.plugin[i] try { - data.plugin[i] = import.meta.resolve!(plugin, configFilepath) + if (plugin.startsWith("file://")) continue + const resolved = Bun.resolveSync(plugin, configFilepath) + data.plugin[i] = pathToFileURL(resolved).href } catch (err) {} } } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index decd18446c19..c5b36b4b9efd 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -578,8 +578,8 @@ 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 - const expected = import.meta.resolve("@scope/plugin", baseUrl) + const configPath = path.join(tmp.path, "opencode.json") + const expected = pathToFileURL(Bun.resolveSync("@scope/plugin", configPath)).href expect(pluginEntries.includes(expected)).toBe(true) From d15502e581d2fb1ef28f277203843058f7bb2ac0 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:22:14 +1000 Subject: [PATCH 27/57] fix(windows): normalize MultiEditTool file paths --- packages/opencode/src/tool/multiedit.ts | 6 +- .../test/tool/multiedit-paths.test.ts | 65 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/tool/multiedit-paths.test.ts diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index b136c53c81b8..286abe894d58 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -21,12 +21,14 @@ export const MultiEditTool = Tool.define("multiedit", { .describe("Array of edit operations to perform sequentially on the file"), }), async execute(params, ctx) { + const input = path.toPosix(params.filePath) + const filePath = path.isAbsolute(input) ? input : path.resolve(Instance.directory, input) const tool = await EditTool.init() const results = [] for (const [, edit] of params.edits.entries()) { const result = await tool.execute( { - filePath: params.filePath, + filePath, oldString: edit.oldString, newString: edit.newString, replaceAll: edit.replaceAll, @@ -36,7 +38,7 @@ export const MultiEditTool = Tool.define("multiedit", { results.push(result) } return { - title: path.relative(Instance.worktree, params.filePath), + title: path.relative(Instance.worktree, filePath), metadata: { results: results.map((r) => r.metadata), }, diff --git a/packages/opencode/test/tool/multiedit-paths.test.ts b/packages/opencode/test/tool/multiedit-paths.test.ts new file mode 100644 index 000000000000..c4036f2d5f08 --- /dev/null +++ b/packages/opencode/test/tool/multiedit-paths.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import nodePath from "node:path" +import { toPosix } from "@opencode-ai/util/path" +import { MultiEditTool } from "../../src/tool/multiedit" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { FileTime } from "../../src/file/time" + +const isWin = process.platform === "win32" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.multiedit path normalization", () => { + if (!isWin) return + + test("windows: accepts MSYS-style absolute file paths", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + lsp: false, + }, + init: async (dir) => { + await Bun.write(nodePath.join(dir, "a.txt"), "hello") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await MultiEditTool.init() + + const expected = toPosix(nodePath.join(tmp.path, "a.txt")) + FileTime.read(ctx.sessionID, expected) + + const msysDir = toPosix(tmp.path).replace(/^([a-zA-Z]):\//, (_, d) => `/${d.toLowerCase()}/`) + const inputPath = `${msysDir}/a.txt` + + await tool.execute( + { + filePath: inputPath, + edits: [ + { + filePath: inputPath, + oldString: "hello", + newString: "world", + }, + ], + }, + ctx, + ) + + expect(await Bun.file(expected).text()).toBe("world") + }, + }) + }) +}) From ac9832dca54537ae5a42bf3df849ec03156e7499 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:22:44 +1000 Subject: [PATCH 28/57] fix(windows): normalize file URLs and LSP paths --- packages/opencode/src/lsp/client.ts | 6 ++++-- packages/opencode/src/session/prompt.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 6d002a7f4586..0418881fd583 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -146,7 +146,8 @@ export namespace LSPClient { }, notify: { async open(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) + const filepath = path.toPosix(input.path) + input.path = path.isAbsolute(filepath) ? filepath : path.resolve(Instance.directory, filepath) const file = Bun.file(input.path) const text = await file.text() const extension = path.extname(input.path) @@ -208,8 +209,9 @@ export namespace LSPClient { return diagnostics }, async waitForDiagnostics(input: { path: string }) { + const filepath = path.toPosix(input.path) const normalizedPath = Filesystem.normalizePath( - path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), + path.isAbsolute(filepath) ? filepath : path.resolve(Instance.directory, filepath), ) log.info("waiting for diagnostics", { path: normalizedPath }) let unsub: () => void diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b8e95588b834..ce9295e63fdd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -951,7 +951,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 = path.toPosix(fileURLToPath(part.url)) const stat = await Bun.file(filepath).stat() if (stat.isDirectory()) { From 44cdc4c740b592a53f23b11995545d923cb8267c Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:30:41 +1000 Subject: [PATCH 29/57] fix(windows): stabilize markdown and ide tests --- packages/opencode/src/config/markdown.ts | 2 +- packages/opencode/test/ide/ide.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index d1eeeac38214..f1b7e440e996 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -19,7 +19,7 @@ export namespace ConfigMarkdown { if (!match) return content const frontmatter = match[1] - const lines = frontmatter.split("\n") + const lines = frontmatter.split(/\r?\n/) const result: string[] = [] for (const line of lines) { diff --git a/packages/opencode/test/ide/ide.test.ts b/packages/opencode/test/ide/ide.test.ts index 4d70140197fe..43efe862a8f9 100644 --- a/packages/opencode/test/ide/ide.test.ts +++ b/packages/opencode/test/ide/ide.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test, afterEach } from "bun:test" import { Ide } from "../../src/ide" describe("ide", () => { - const original = structuredClone(process.env) + const original = Object.fromEntries( + Object.entries(process.env).filter((x): x is [string, string] => x[1] !== undefined), + ) afterEach(() => { Object.keys(process.env).forEach((key) => { From cd4113d3083661714428f16260bccb8a11c1d2f3 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:52:47 +1000 Subject: [PATCH 30/57] test(windows): normalize path expectations --- .../opencode/test/project/project.test.ts | 45 +++++--- .../opencode/test/snapshot/snapshot.test.ts | 100 +++++++++++------- .../opencode/test/tool/apply_patch.test.ts | 3 +- 3 files changed, 91 insertions(+), 57 deletions(-) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index d44e606746e1..b7bbc50de426 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -4,6 +4,7 @@ import { Log } from "../../src/util/log" import { Storage } from "../../src/storage/storage" import { $ } from "bun" import path from "path" +import { toPosix } from "@opencode-ai/util/path" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) @@ -18,7 +19,7 @@ describe("Project.fromDirectory", () => { expect(project).toBeDefined() expect(project.id).toBe("global") expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(toPosix(tmp.path)) const opencodeFile = path.join(tmp.path, ".git", "opencode") const fileExists = await Bun.file(opencodeFile).exists() @@ -33,7 +34,7 @@ describe("Project.fromDirectory", () => { expect(project).toBeDefined() expect(project.id).not.toBe("global") expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(toPosix(tmp.path)) const opencodeFile = path.join(tmp.path, ".git", "opencode") const fileExists = await Bun.file(opencodeFile).exists() @@ -47,23 +48,27 @@ describe("Project.fromDirectory with worktrees", () => { const { project, sandbox } = await Project.fromDirectory(tmp.path) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - expect(project.sandboxes).not.toContain(tmp.path) + expect(project.worktree).toBe(toPosix(tmp.path)) + expect(sandbox).toBe(toPosix(tmp.path)) + expect(project.sandboxes).not.toContain(toPosix(tmp.path)) }) test("should set worktree to root when called from a worktree", async () => { await using tmp = await tmpdir({ git: true }) - const worktreePath = path.join(tmp.path, "..", "worktree-test") + const worktreePath = path.join( + tmp.path, + "..", + `worktree-test-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ) await $`git worktree add ${worktreePath} -b test-branch`.cwd(tmp.path).quiet() const { project, sandbox } = await Project.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(project.worktree).toBe(toPosix(tmp.path)) + expect(sandbox).toBe(toPosix(worktreePath)) + expect(project.sandboxes).toContain(toPosix(worktreePath)) + expect(project.sandboxes).not.toContain(toPosix(tmp.path)) await $`git worktree remove ${worktreePath}`.cwd(tmp.path).quiet() }) @@ -71,18 +76,26 @@ describe("Project.fromDirectory with worktrees", () => { test("should accumulate multiple worktrees in sandboxes", async () => { await using tmp = await tmpdir({ git: true }) - const worktree1 = path.join(tmp.path, "..", "worktree-1") - const worktree2 = path.join(tmp.path, "..", "worktree-2") + const worktree1 = path.join( + tmp.path, + "..", + `worktree-1-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ) + const worktree2 = path.join( + tmp.path, + "..", + `worktree-2-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ) await $`git worktree add ${worktree1} -b branch-1`.cwd(tmp.path).quiet() await $`git worktree add ${worktree2} -b branch-2`.cwd(tmp.path).quiet() await Project.fromDirectory(worktree1) const { project } = await Project.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(project.worktree).toBe(toPosix(tmp.path)) + expect(project.sandboxes).toContain(toPosix(worktree1)) + expect(project.sandboxes).toContain(toPosix(worktree2)) + expect(project.sandboxes).not.toContain(toPosix(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/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index de58f4f85e67..0bc60541a545 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -3,6 +3,7 @@ import { $ } from "bun" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" +import { toPosix } from "@opencode-ai/util/path" async function bootstrap() { return tmpdir({ @@ -23,6 +24,8 @@ async function bootstrap() { }) } +const p = (x: string) => toPosix(x) + test("tracks deleted files correctly", async () => { await using tmp = await bootstrap() await Instance.provide({ @@ -33,7 +36,7 @@ test("tracks deleted files correctly", async () => { await $`rm ${tmp.path}/a.txt`.quiet() - expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/a.txt`) + expect((await Snapshot.patch(before!)).files).toContain(p(`${tmp.path}/a.txt`)) }, }) }) @@ -126,7 +129,7 @@ test("binary file handling", async () => { await Bun.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47])) const patch = await Snapshot.patch(before!) - expect(patch.files).toContain(`${tmp.path}/image.png`) + expect(patch.files).toContain(p(`${tmp.path}/image.png`)) await Snapshot.revert([patch]) expect(await Bun.file(`${tmp.path}/image.png`).exists()).toBe(false) @@ -144,7 +147,7 @@ test("symlink handling", async () => { await $`ln -s ${tmp.path}/a.txt ${tmp.path}/link.txt`.quiet() - expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/link.txt`) + expect((await Snapshot.patch(before!)).files).toContain(p(`${tmp.path}/link.txt`)) }, }) }) @@ -159,7 +162,7 @@ test("large file handling", async () => { await Bun.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024)) - expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/large.txt`) + expect((await Snapshot.patch(before!)).files).toContain(p(`${tmp.path}/large.txt`)) }, }) }) @@ -195,9 +198,9 @@ test("special characters in filenames", async () => { await Bun.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES") const files = (await Snapshot.patch(before!)).files - expect(files).toContain(`${tmp.path}/file with spaces.txt`) - expect(files).toContain(`${tmp.path}/file-with-dashes.txt`) - expect(files).toContain(`${tmp.path}/file_with_underscores.txt`) + expect(files).toContain(p(`${tmp.path}/file with spaces.txt`)) + expect(files).toContain(p(`${tmp.path}/file-with-dashes.txt`)) + expect(files).toContain(p(`${tmp.path}/file_with_underscores.txt`)) }, }) }) @@ -266,10 +269,10 @@ test("unicode filenames", async () => { expect(before).toBeTruthy() const unicodeFiles = [ - { path: `${tmp.path}/文件.txt`, content: "chinese content" }, - { path: `${tmp.path}/🚀rocket.txt`, content: "emoji content" }, - { path: `${tmp.path}/café.txt`, content: "accented content" }, - { path: `${tmp.path}/файл.txt`, content: "cyrillic content" }, + { path: p(`${tmp.path}/文件.txt`), content: "chinese content" }, + { path: p(`${tmp.path}/🚀rocket.txt`), content: "emoji content" }, + { path: p(`${tmp.path}/café.txt`), content: "accented content" }, + { path: p(`${tmp.path}/файл.txt`), content: "cyrillic content" }, ] for (const file of unicodeFiles) { @@ -297,8 +300,8 @@ test("unicode filenames modification and restore", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const chineseFile = `${tmp.path}/文件.txt` - const cyrillicFile = `${tmp.path}/файл.txt` + const chineseFile = p(`${tmp.path}/文件.txt`) + const cyrillicFile = p(`${tmp.path}/файл.txt`) await Bun.write(chineseFile, "original chinese") await Bun.write(cyrillicFile, "original cyrillic") @@ -330,7 +333,7 @@ test("unicode filenames in subdirectories", async () => { expect(before).toBeTruthy() await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet() - const deepFile = `${tmp.path}/目录/подкаталог/文件.txt` + const deepFile = p(`${tmp.path}/目录/подкаталог/文件.txt`) await Bun.write(deepFile, "deep unicode content") const patch = await Snapshot.patch(before!) @@ -351,9 +354,22 @@ test("very long filenames", async () => { expect(before).toBeTruthy() const longName = "a".repeat(200) + ".txt" - const longFile = `${tmp.path}/${longName}` - - await Bun.write(longFile, "long filename content") + const longFile = p(`${tmp.path}/${longName}`) + + try { + await Bun.write(longFile, "long filename content") + } catch (err) { + if ( + process.platform === "win32" && + err && + typeof err === "object" && + "code" in err && + err.code === "ENAMETOOLONG" + ) { + return + } + throw err + } const patch = await Snapshot.patch(before!) expect(patch.files).toContain(longFile) @@ -377,9 +393,9 @@ test("hidden files", async () => { await Bun.write(`${tmp.path}/.config`, "config content") const patch = await Snapshot.patch(before!) - expect(patch.files).toContain(`${tmp.path}/.hidden`) - expect(patch.files).toContain(`${tmp.path}/.gitignore`) - expect(patch.files).toContain(`${tmp.path}/.config`) + expect(patch.files).toContain(p(`${tmp.path}/.hidden`)) + expect(patch.files).toContain(p(`${tmp.path}/.gitignore`)) + expect(patch.files).toContain(p(`${tmp.path}/.config`)) }, }) }) @@ -398,8 +414,12 @@ test("nested symlinks", async () => { await $`ln -s ${tmp.path}/sub ${tmp.path}/sub-link`.quiet() const patch = await Snapshot.patch(before!) - expect(patch.files).toContain(`${tmp.path}/sub/dir/link.txt`) - expect(patch.files).toContain(`${tmp.path}/sub-link`) + expect(patch.files).toContain(p(`${tmp.path}/sub/dir/link.txt`)) + if (process.platform !== "win32") { + expect(patch.files).toContain(p(`${tmp.path}/sub-link`)) + } else { + expect(patch.files.some((x) => x.includes("/sub-link/"))).toBe(true) + } }, }) }) @@ -457,11 +477,11 @@ test("gitignore changes", async () => { const patch = await Snapshot.patch(before!) // Should track gitignore itself - expect(patch.files).toContain(`${tmp.path}/.gitignore`) + expect(patch.files).toContain(p(`${tmp.path}/.gitignore`)) // Should track normal files - expect(patch.files).toContain(`${tmp.path}/normal.txt`) + expect(patch.files).toContain(p(`${tmp.path}/normal.txt`)) // Should not track ignored files (git won't see them) - expect(patch.files).not.toContain(`${tmp.path}/test.ignored`) + expect(patch.files).not.toContain(p(`${tmp.path}/test.ignored`)) }, }) }) @@ -506,7 +526,7 @@ test("snapshot state isolation between projects", async () => { const before1 = await Snapshot.track() await Bun.write(`${tmp1.path}/project1.txt`, "project1 content") const patch1 = await Snapshot.patch(before1!) - expect(patch1.files).toContain(`${tmp1.path}/project1.txt`) + expect(patch1.files).toContain(p(`${tmp1.path}/project1.txt`)) }, }) @@ -516,17 +536,17 @@ test("snapshot state isolation between projects", async () => { const before2 = await Snapshot.track() await Bun.write(`${tmp2.path}/project2.txt`, "project2 content") const patch2 = await Snapshot.patch(before2!) - expect(patch2.files).toContain(`${tmp2.path}/project2.txt`) + expect(patch2.files).toContain(p(`${tmp2.path}/project2.txt`)) // Ensure project1 files don't appear in project2 - expect(patch2.files).not.toContain(`${tmp1?.path}/project1.txt`) + expect(patch2.files).not.toContain(p(`${tmp1?.path}/project1.txt`)) }, }) }) test("patch detects changes in secondary worktree", async () => { await using tmp = await bootstrap() - const worktreePath = `${tmp.path}-worktree` + const worktreePath = p(`${tmp.path}-worktree`) await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { @@ -558,7 +578,7 @@ test("patch detects changes in secondary worktree", async () => { test("revert only removes files in invoking worktree", async () => { await using tmp = await bootstrap() - const worktreePath = `${tmp.path}-worktree` + const worktreePath = p(`${tmp.path}-worktree`) await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { @@ -568,7 +588,7 @@ test("revert only removes files in invoking worktree", async () => { expect(await Snapshot.track()).toBeTruthy() }, }) - const primaryFile = `${tmp.path}/worktree.txt` + const primaryFile = p(`${tmp.path}/worktree.txt`) await Bun.write(primaryFile, "primary content") await Instance.provide({ @@ -591,13 +611,13 @@ test("revert only removes files in invoking worktree", async () => { } finally { await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() await $`rm -rf ${worktreePath}`.quiet() - await $`rm -f ${tmp.path}/worktree.txt`.quiet() + await $`rm -f ${p(`${tmp.path}/worktree.txt`)}`.quiet() } }) test("diff reports worktree-only/shared edits and ignores primary-only", async () => { await using tmp = await bootstrap() - const worktreePath = `${tmp.path}-worktree` + const worktreePath = p(`${tmp.path}-worktree`) await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { @@ -616,8 +636,8 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( await Bun.write(`${worktreePath}/worktree-only.txt`, "worktree diff content") await Bun.write(`${worktreePath}/shared.txt`, "worktree edit") - await Bun.write(`${tmp.path}/shared.txt`, "primary edit") - await Bun.write(`${tmp.path}/primary-only.txt`, "primary change") + await Bun.write(p(`${tmp.path}/shared.txt`), "primary edit") + await Bun.write(p(`${tmp.path}/primary-only.txt`), "primary change") const diff = await Snapshot.diff(before!) expect(diff).toContain("worktree-only.txt") @@ -628,8 +648,8 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( } finally { await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() await $`rm -rf ${worktreePath}`.quiet() - await $`rm -f ${tmp.path}/shared.txt`.quiet() - await $`rm -f ${tmp.path}/primary-only.txt`.quiet() + await $`rm -f ${p(`${tmp.path}/shared.txt`)}`.quiet() + await $`rm -f ${p(`${tmp.path}/primary-only.txt`)}`.quiet() } }) @@ -713,7 +733,7 @@ test("revert should not delete files that existed but were deleted in snapshot", await Bun.write(`${tmp.path}/a.txt`, "recreated content") const patch = await Snapshot.patch(snapshot2!) - expect(patch.files).toContain(`${tmp.path}/a.txt`) + expect(patch.files).toContain(p(`${tmp.path}/a.txt`)) await Snapshot.revert([patch]) @@ -737,8 +757,8 @@ test("revert preserves file that existed in snapshot when deleted then recreated await Bun.write(`${tmp.path}/newfile.txt`, "new") const patch = await Snapshot.patch(snapshot!) - expect(patch.files).toContain(`${tmp.path}/existing.txt`) - expect(patch.files).toContain(`${tmp.path}/newfile.txt`) + expect(patch.files).toContain(p(`${tmp.path}/existing.txt`)) + expect(patch.files).toContain(p(`${tmp.path}/newfile.txt`)) await Snapshot.revert([patch]) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index a08e235885af..39415621c178 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import * as fs from "fs/promises" +import { toPosix } from "@opencode-ai/util/path" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -142,7 +143,7 @@ describe("tool.apply_patch freeform", () => { const moveFile = permissionCall.metadata.files[0] expect(moveFile.type).toBe("move") expect(moveFile.relativePath).toBe("renamed/dir/name.txt") - expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt")) + expect(moveFile.movePath).toBe(toPosix(path.join(fixture.path, "renamed/dir/name.txt"))) expect(moveFile.before).toBe("old content\n") expect(moveFile.after).toBe("new content\n") }, From 3cc926695d62332b3137c96182a3ae3bb3764cea Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:58:42 +1000 Subject: [PATCH 31/57] fix(i18n): remove duplicate zh keys --- packages/app/src/i18n/zh.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index c129a57813c1..99adc30a4572 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -529,9 +529,6 @@ export const dict = { "settings.general.row.releaseNotes.title": "发行说明", "settings.general.row.releaseNotes.description": "更新后显示“新功能”弹窗", - "settings.general.row.releaseNotes.title": "发行说明", - "settings.general.row.releaseNotes.description": "更新后显示“新功能”弹窗", - "settings.updates.row.startup.title": "启动时检查更新", "settings.updates.row.startup.description": "在 OpenCode 启动时自动检查更新", "settings.updates.row.check.title": "检查更新", From e9557a9259e79334f33a271b1234339311ae0e66 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:12:18 +1000 Subject: [PATCH 32/57] ci: run opencode unit tests on Windows --- .github/workflows/test.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d95de94d232c..5b6da96a42d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,8 +25,10 @@ jobs: - name: windows host: windows-latest playwright: bunx playwright install - workdir: packages/app - command: bun test:e2e:local + workdir: . + command: | + bun --cwd packages/opencode test + bun --cwd packages/app test:e2e:local runs-on: ${{ matrix.settings.host }} defaults: run: From 8a808f07384740014a7d6401aa3c8c31e5a1f307 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:17:41 +1000 Subject: [PATCH 33/57] fix(windows): normalize permission matching and config file refs --- packages/opencode/src/config/config.ts | 3 +- packages/opencode/src/permission/next.ts | 17 ++++++++--- packages/opencode/test/config/config.test.ts | 30 +++++++++++++++++++ .../opencode/test/permission/next.test.ts | 13 ++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 565133bf05a1..e0e715b4090c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1163,7 +1163,8 @@ export namespace Config { if (filePath.startsWith("~/")) { filePath = path.join(os.homedir(), filePath.slice(2)) } - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) + const inputPath = path.toPosix(filePath) + const resolvedPath = path.isAbsolute(inputPath) ? inputPath : path.resolve(configDir, inputPath) const fileContent = ( await Bun.file(resolvedPath) .text() diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 39261fd41583..8c57e5326791 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -238,10 +238,19 @@ export namespace PermissionNext { export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { const merged = merge(...rulesets) - log.info("evaluate", { permission, pattern, ruleset: merged }) - const match = merged.findLast( - (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), - ) + const isPath = PATH_PERMISSIONS.has(permission) + + const target = isPath ? toPosix(pattern) : pattern + const targetKey = isPath && process.platform === "win32" ? target.toLowerCase() : target + + log.info("evaluate", { permission, pattern: target, ruleset: merged }) + + const match = merged.findLast((rule) => { + if (!Wildcard.match(permission, rule.permission)) return false + const candidate = isPath ? toPosix(rule.pattern) : rule.pattern + const candidateKey = isPath && process.platform === "win32" ? candidate.toLowerCase() : candidate + return Wildcard.match(targetKey, candidateKey) + }) return match ?? { action: "ask", permission, pattern: "*" } } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index c5b36b4b9efd..f4622745afb5 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" import { pathToFileURL } from "url" +import { toPosix } from "@opencode-ai/util/path" test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() @@ -187,6 +188,35 @@ test("handles file inclusion substitution", async () => { }) }) +test("windows: handles file inclusion substitution with MSYS absolute paths", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "included.txt"), "test_theme") + + const msysDir = toPosix(dir).replace(/^([a-zA-Z]):\//, (_, d) => `/${d.toLowerCase()}/`) + const included = `${msysDir}/included.txt` + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + theme: `{file:${included}}`, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.theme).toBe("test_theme") + }, + }) +}) + test("validates config schema and throws on invalid fields", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 5bb50b578396..8cfcbd23b51f 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -81,6 +81,19 @@ if (isWin) { const result = PermissionNext.fromConfig({ external_directory: { "/c/Users/Luke/*": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: "C:/Users/Luke/*", action: "allow" }]) }) + + test("evaluate - matches stored backslash patterns for path permissions", () => { + const ruleset: PermissionNext.Ruleset = [ + { permission: "external_directory", pattern: "c:\\Users\\Luke\\*", action: "allow" }, + ] + expect(PermissionNext.evaluate("external_directory", "C:/Users/Luke/file.txt", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("external_directory", "/c/Users/Luke/file.txt", ruleset).action).toBe("allow") + }) + + test("evaluate - matches case-insensitively for path permissions", () => { + const ruleset: PermissionNext.Ruleset = [{ permission: "read", pattern: "C:/USERS/LUKE/*", action: "allow" }] + expect(PermissionNext.evaluate("read", "c:/users/luke/file.txt", ruleset).action).toBe("allow") + }) } test("evaluate - matches expanded tilde pattern", () => { From 87d833b786515ff9839af7a558cd8b48a774c8be Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:00:00 +1000 Subject: [PATCH 34/57] fix(windows): handle extended-length paths in toPosix --- packages/opencode/test/util/path.test.ts | 10 ++++++++++ packages/util/src/path.ts | 14 +++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/util/path.test.ts b/packages/opencode/test/util/path.test.ts index 219d90ea62f6..5743713fd363 100644 --- a/packages/opencode/test/util/path.test.ts +++ b/packages/opencode/test/util/path.test.ts @@ -44,4 +44,14 @@ describe("util.path", () => { expect(path.toPosix("C:/Users/Luke\\dev")).toBe("C:/Users/Luke/dev") expect(path.toPosix("c:\\Users\\Luke\\dev")).toBe("C:/Users/Luke/dev") }) + + test("windows: converts extended-length drive paths", () => { + expect(path.toPosix("\\\\?\\C:\\Users\\Luke\\file.txt")).toBe("C:/Users/Luke/file.txt") + expect(path.toPosix("//?/C:/Users/Luke/file.txt")).toBe("C:/Users/Luke/file.txt") + }) + + test("windows: converts extended-length UNC paths", () => { + expect(path.toPosix("\\\\?\\UNC\\server\\share\\file.txt")).toBe("//server/share/file.txt") + expect(path.toPosix("//?/UNC/server/share/file.txt")).toBe("//server/share/file.txt") + }) }) diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index d5eaddc5f17f..a3849c707365 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -20,8 +20,20 @@ export function toPosix(p: string) { if (!isWin) return p const slashed = p.replace(/\\/g, "/") - const msys = slashed.replace(/^\/(?:cygdrive\/|mnt\/)?([a-zA-Z])(?:\/|$)/, (_, d) => `${d.toUpperCase()}:/`) + + // Windows extended-length paths: + // - Drive: \\\?\\C:\\foo -> //?/C:/foo -> C:/foo + // - UNC: \\\?\\UNC\\srv\\sh -> //?/UNC/srv/sh -> //srv/sh + const extendedUnc = slashed.replace(/^\/\/\?\/UNC\//i, "//") + const extendedDrive = extendedUnc.replace(/^\/\/\?\/([a-zA-Z]):\//, (_, d) => `${d.toUpperCase()}:/`) + + // MSYS/Cygwin/WSL drive roots + const msys = extendedDrive.replace(/^\/(?:cygdrive\/|mnt\/)?([a-zA-Z])(?:\/|$)/, (_, d) => `${d.toUpperCase()}:/`) + + // Normalize drive letter casing const res = msys.replace(/^([a-z]):\//, (_, d) => `${d.toUpperCase()}:/`) + + // If the path is a UNC share root with a trailing slash, trim it. if (/^\/\/[^/]+\/[^/]+\/$/.test(res)) return res.slice(0, -1) return res } From 0251437988b4403d4c48c108a0ec138af001b006 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:36:06 +1000 Subject: [PATCH 35/57] fix(windows): canonicalize Instance directory with realpath --- packages/opencode/src/project/instance.ts | 2 +- .../test/project/instance-paths.test.ts | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/project/instance-paths.test.ts diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index cf90473f8ed0..c468db40e2c0 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -21,7 +21,7 @@ const disposal = { export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = path.resolve(input.directory) + const directory = Filesystem.normalizePath(path.resolve(input.directory)) let existing = cache.get(directory) if (!existing) { Log.Default.info("creating instance", { directory }) diff --git a/packages/opencode/test/project/instance-paths.test.ts b/packages/opencode/test/project/instance-paths.test.ts new file mode 100644 index 000000000000..ac72ca0b6108 --- /dev/null +++ b/packages/opencode/test/project/instance-paths.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { toPosix } from "@opencode-ai/util/path" +import { realpathSync } from "node:fs" +import os from "node:os" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("Instance directory normalization", () => { + if (process.platform !== "win32") return + + test("canonicalizes short and long paths to same directory", async () => { + await using tmp = await tmpdir({ git: true }) + + const longRoot = toPosix(realpathSync.native(os.tmpdir())) + const shortRoot = toPosix(os.tmpdir()) + const long = toPosix(tmp.path) + expect(long.startsWith(longRoot)).toBe(true) + + const suffix = long.slice(longRoot.length) + const short = toPosix(shortRoot + suffix) + + const dir1 = await Instance.provide({ + directory: long, + fn: async () => Instance.directory, + }) + const dir2 = await Instance.provide({ + directory: short, + fn: async () => Instance.directory, + }) + + expect(dir1).toBe(dir2) + expect(dir1).not.toContain("\\") + }) +}) From ce64d51fbc29cd561350445f2f96fa73470085ee Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:34:42 +1000 Subject: [PATCH 36/57] align --- packages/opencode/src/config/config.ts | 5 +- packages/opencode/src/lsp/client.ts | 4 +- packages/opencode/src/lsp/index.ts | 29 +++- packages/opencode/src/patch/index.ts | 1 + packages/opencode/src/pty/index.ts | 7 +- packages/opencode/src/session/prompt.ts | 10 +- packages/opencode/src/tool/apply_patch.ts | 2 +- packages/opencode/src/tool/bash.ts | 3 + packages/opencode/src/tool/edit.ts | 2 +- packages/opencode/src/tool/write.ts | 6 +- packages/opencode/src/util/path.ts | 12 +- packages/opencode/test/config/config.test.ts | 33 +++++ .../opencode/test/config/markdown.test.ts | 3 +- packages/opencode/test/file/ripgrep.test.ts | 3 +- packages/opencode/test/fixture/fixture.ts | 24 ++- packages/opencode/test/lsp/index.test.ts | 39 +++++ packages/opencode/test/server/pty.test.ts | 20 +++ packages/opencode/test/session/prompt.test.ts | 87 +++++++++++ packages/opencode/test/tool/bash.test.ts | 20 +++ .../test/tool/diagnostics-paths.test.ts | 138 ++++++++++++++++++ packages/ui/src/components/message-part.tsx | 5 +- packages/ui/src/custom-elements.d.ts | 18 +-- 22 files changed, 426 insertions(+), 45 deletions(-) create mode 100644 packages/opencode/test/lsp/index.test.ts create mode 100644 packages/opencode/test/server/pty.test.ts create mode 100644 packages/opencode/test/session/prompt.test.ts create mode 100644 packages/opencode/test/tool/diagnostics-paths.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 57d7ddcdf206..f218d378a6ec 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -257,10 +257,11 @@ export namespace Config { } function rel(item: string, patterns: string[]) { + const normalized = path.toPosix(item) for (const pattern of patterns) { - const index = item.indexOf(pattern) + const index = normalized.indexOf(pattern) if (index === -1) continue - return item.slice(index + pattern.length) + return normalized.slice(index + pattern.length) } } diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 0418881fd583..a6b3bb384723 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -50,13 +50,15 @@ export namespace LSPClient { const diagnostics = new Map() connection.onNotification("textDocument/publishDiagnostics", (params) => { - const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) + const rawPath = path.toPosix(fileURLToPath(params.uri)) + const filePath = Filesystem.normalizePath(rawPath) l.info("textDocument/publishDiagnostics", { path: filePath, count: params.diagnostics.length, }) const exists = diagnostics.has(filePath) diagnostics.set(filePath, params.diagnostics) + if (rawPath !== filePath) diagnostics.set(rawPath, params.diagnostics) if (!exists && input.serverID === "typescript") return Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) }) diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 774dacbcb2b3..a999f5bed40a 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -3,6 +3,7 @@ import { Bus } from "@/bus" import { Log } from "../util/log" import { LSPClient } from "./client" import path from "@/util/path" +import { Filesystem } from "@/util/filesystem" import { pathToFileURL } from "url" import { LSPServer } from "./server" import z from "zod" @@ -226,15 +227,22 @@ export namespace LSP { const root = await server.root(file) if (!root) continue - if (s.broken.has(root + server.id)) continue + const normalized = path.toPosix(root) + if (!(await Filesystem.isDir(normalized))) { + log.error("LSP root is not a directory", { root: normalized, serverID: server.id }) + s.broken.add(normalized + server.id) + continue + } + const key = normalized + server.id + if (s.broken.has(key)) continue - const match = s.clients.find((x) => x.root === root && x.serverID === server.id) + const match = s.clients.find((x) => x.root === normalized && x.serverID === server.id) if (match) { result.push(match) continue } - const inflight = s.spawning.get(root + server.id) + const inflight = s.spawning.get(key) if (inflight) { const client = await inflight if (!client) continue @@ -242,12 +250,12 @@ export namespace LSP { continue } - const task = schedule(server, root, root + server.id) - s.spawning.set(root + server.id, task) + const task = schedule(server, normalized, key) + s.spawning.set(key, task) task.finally(() => { - if (s.spawning.get(root + server.id) === task) { - s.spawning.delete(root + server.id) + if (s.spawning.get(key) === task) { + s.spawning.delete(key) } }) @@ -268,7 +276,12 @@ export namespace LSP { if (server.extensions.length && !server.extensions.includes(extension)) continue const root = await server.root(file) if (!root) continue - if (s.broken.has(root + server.id)) continue + const normalized = path.toPosix(root) + if (!(await Filesystem.isDir(normalized))) { + s.broken.add(normalized + server.id) + continue + } + if (s.broken.has(normalized + server.id)) continue return true } return false diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index a3135338b4ee..3ac6440387f4 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -76,6 +76,7 @@ export namespace Patch { lines: string[], startIdx: number, ): { filePath: string; movePath?: string; nextIdx: number } | null { + if (startIdx >= lines.length) return null const line = lines[startIdx] const addPrefix = "*** Add File:" diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index d01b2b02e91f..6408e28cfd64 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -6,6 +6,8 @@ import { Identifier } from "../id/id" import { Log } from "../util/log" import type { WSContext } from "hono/ws" import { Instance } from "../project/instance" +import path from "@/util/path" +import { Filesystem } from "@/util/filesystem" import { lazy } from "@opencode-ai/util/lazy" import { Shell } from "@/shell/shell" @@ -101,7 +103,10 @@ export namespace Pty { args.push("-l") } - const cwd = input.cwd || Instance.directory + const cwd = path.resolve(Instance.directory, input.cwd || Instance.directory) + if (!(await Filesystem.isDir(cwd))) { + throw new Error(`Invalid cwd: ${cwd}`) + } const env = { ...process.env, ...input.env, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b110cae965ec..679c7fc8457c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -856,8 +856,9 @@ export namespace SessionPrompt { } using _ = defer(() => InstructionPrompt.clear(info.id)) - const parts = await Promise.all( - input.parts.map(async (part): Promise => { + const parts: MessageV2.Part[] = [] + for (const part of input.parts) { + const items = await (async (): Promise => { if (part.type === "file") { // before checking the protocol we check if this is an mcp resource because it needs special handling if (part.source?.type === "resource") { @@ -1182,8 +1183,9 @@ export namespace SessionPrompt { sessionID: input.sessionID, }, ] - }), - ).then((x) => x.flat()) + })() + parts.push(...items) + } await Plugin.trigger( "chat.message", diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index c2d67db4b7ba..7cff6f336d6c 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -258,7 +258,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { if (change.type === "delete") continue const target = change.movePath ?? change.filePath const normalized = Filesystem.normalizePath(target) - const issues = diagnostics[normalized] ?? [] + const issues = diagnostics[normalized] ?? diagnostics[path.toPosix(target)] ?? [] const errors = issues.filter((item) => item.severity === 1) if (errors.length > 0) { const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f810d4aa3464..7a6f9429f446 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -82,6 +82,9 @@ export const BashTool = Tool.define("bash", async () => { }), async execute(params, ctx) { const cwd = path.resolve(params.workdir || Instance.directory) + if (!(await Filesystem.isDir(cwd))) { + throw new Error(`Invalid working directory: ${cwd}`) + } if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 5e2ff8e00ef5..23b5e4a5a896 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -134,7 +134,7 @@ export const EditTool = Tool.define("edit", { await LSP.touchFile(filePath, true) const diagnostics = await LSP.diagnostics() const normalizedFilePath = Filesystem.normalizePath(filePath) - const issues = diagnostics[normalizedFilePath] ?? [] + const issues = diagnostics[normalizedFilePath] ?? diagnostics[path.toPosix(filePath)] ?? [] const errors = issues.filter((item) => item.severity === 1) if (errors.length > 0) { const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 093872733488..9e5fe5198c07 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -57,14 +57,18 @@ export const WriteTool = Tool.define("write", { await LSP.touchFile(filepath, true) const diagnostics = await LSP.diagnostics() const normalizedFilepath = Filesystem.normalizePath(filepath) + const seen = new Set() let projectDiagnosticsCount = 0 for (const [file, issues] of Object.entries(diagnostics)) { + const key = Filesystem.normalizePath(file) + if (seen.has(key)) continue + seen.add(key) const errors = issues.filter((item) => item.severity === 1) if (errors.length === 0) continue 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` : "" - if (file === normalizedFilepath) { + if (key === normalizedFilepath) { output += `\n\nLSP errors detected in this file, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` continue } diff --git a/packages/opencode/src/util/path.ts b/packages/opencode/src/util/path.ts index 75b0b77aa533..37d025eab4b8 100644 --- a/packages/opencode/src/util/path.ts +++ b/packages/opencode/src/util/path.ts @@ -1,9 +1,19 @@ +import os from "os" import nodePath from "node:path" -import { toPosix } from "@opencode-ai/util/path" +import { toPosix as baseToPosix } from "@opencode-ai/util/path" const isWin = process.platform === "win32" +function toPosix(p: string) { + const res = baseToPosix(p) + if (!isWin) return res + // Git Bash/MSYS uses /tmp; map it to the real Windows temp dir. + if (res === "/tmp") return baseToPosix(os.tmpdir()) + if (res.startsWith("/tmp/")) return baseToPosix(nodePath.join(os.tmpdir(), res.slice("/tmp/".length))) + return res +} + function normalizeArgs(args: string[]) { if (!isWin) return args return args.map((x) => toPosix(x)) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index a6aac0db633a..ee25a22c8dcf 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -570,6 +570,39 @@ Nested command template`, }) }) +test("windows: loads commands when instance directory is MSYS path", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + const commandDir = path.join(opencodeDir, "command") + await fs.mkdir(commandDir, { recursive: true }) + await Bun.write( + path.join(commandDir, "hello.md"), + `--- +description: Test command +--- +Hello from MSYS command`, + ) + }, + }) + + const msysDir = toPosix(tmp.path).replace(/^([a-zA-Z]):\//, (_, d) => `/${d.toLowerCase()}/`) + + await Instance.provide({ + directory: msysDir, + fn: async () => { + const config = await Config.get() + expect(config.command?.["hello"]).toEqual({ + description: "Test command", + template: "Hello from MSYS command", + }) + }, + }) +}) + test("updates config and writes to file", async () => { await using tmp = await tmpdir() await Instance.provide({ diff --git a/packages/opencode/test/config/markdown.test.ts b/packages/opencode/test/config/markdown.test.ts index c6133317e2c0..9891aa11fbaf 100644 --- a/packages/opencode/test/config/markdown.test.ts +++ b/packages/opencode/test/config/markdown.test.ts @@ -197,7 +197,8 @@ describe("ConfigMarkdown: frontmatter parsing w/ Markdown header", async () => { test("should parse and match", () => { expect(result).toBeDefined() expect(result.data).toEqual({}) - expect(result.content.trim()).toBe(`# Response Formatting Requirements + const normalized = result.content.trim().replace(/\r\n/g, "\n") + expect(normalized).toBe(`# Response Formatting Requirements Always structure your responses using clear markdown formatting: diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index ac46f1131b09..e0aa1de73b38 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" +import { toPosix } from "@opencode-ai/util/path" import { tmpdir } from "../fixture/fixture" import { Ripgrep } from "../../src/file/ripgrep" @@ -16,7 +17,7 @@ describe("file.ripgrep", () => { const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path })) const hasVisible = files.includes("visible.txt") - const hasHidden = files.includes(path.join(".opencode", "thing.json")) + const hasHidden = files.includes(toPosix(path.join(".opencode", "thing.json"))) expect(hasVisible).toBe(true) expect(hasHidden).toBe(true) }) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index ed8c5e344a81..03b548378860 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -1,7 +1,7 @@ import { $ } from "bun" import * as fs from "fs/promises" import os from "os" -import path from "path" +import path from "@/util/path" import type { Config } from "../../src/config/config" // Strip null bytes from paths (defensive fix for CI environment issues) @@ -19,8 +19,24 @@ 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() + const init = await $`git init`.cwd(dirpath).quiet().nothrow() + if (init.exitCode !== 0) { + console.error("git init failed", { + dirpath, + exitCode: init.exitCode, + stdout: init.stdout.toString(), + stderr: init.stderr.toString(), + }) + } + const commit = await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet().nothrow() + if (commit.exitCode !== 0) { + console.error("git commit failed", { + dirpath, + exitCode: commit.exitCode, + stdout: commit.stdout.toString(), + stderr: commit.stderr.toString(), + }) + } } if (options?.config) { await Bun.write( @@ -32,7 +48,7 @@ export async function tmpdir(options?: TmpDirOptions) { ) } const extra = await options?.init?.(dirpath) - const realpath = sanitizePath(await fs.realpath(dirpath)) + const realpath = path.toPosix(sanitizePath(await fs.realpath(dirpath))) const result = { [Symbol.asyncDispose]: async () => { await options?.dispose?.(dirpath) diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts new file mode 100644 index 000000000000..8455b4a40705 --- /dev/null +++ b/packages/opencode/test/lsp/index.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test, mock } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +mock.module("../../src/lsp/server", () => ({ + LSPServer: { + Bad: { + id: "bad", + extensions: [".txt"], + root: async () => path.join(process.cwd(), "__missing_root__"), + spawn: async () => undefined, + }, + }, +})) + +describe("LSP root validation", () => { + test("skips servers with invalid roots", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "hello") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { LSP } = await import("../../src/lsp") + const file = path.join(tmp.path, "a.txt") + const available = await LSP.hasClients(file) + expect(available).toBe(false) + }, + }) + }) +}) diff --git a/packages/opencode/test/server/pty.test.ts b/packages/opencode/test/server/pty.test.ts new file mode 100644 index 000000000000..7170c0ec3bbf --- /dev/null +++ b/packages/opencode/test/server/pty.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Pty } from "../../src/pty" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("pty.create", () => { + test("throws for invalid cwd", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(Pty.create({ cwd: path.join(tmp.path, "missing") })).rejects.toThrow("Invalid cwd") + }, + }) + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts new file mode 100644 index 000000000000..7821c19e3b74 --- /dev/null +++ b/packages/opencode/test/session/prompt.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test, mock } from "bun:test" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageV2 } from "../../src/session/message-v2" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" +import path from "@/util/path" + +// 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 () => {}, + }, +})) + +Log.init({ print: false }) + +describe("SessionPrompt ordering", () => { + test("keeps @file order with read output parts", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "28\n") + await Bun.write(path.join(dir, "b.txt"), "42\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const template = "What numbers are written in files @a.txt and @b.txt ?" + const parts = await SessionPrompt.resolvePromptParts(template) + const fileParts = parts.filter((part) => part.type === "file") + + expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"]) + + const message = await SessionPrompt.prompt({ + sessionID: session.id, + parts, + noReply: true, + }) + const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const items = stored.parts + const aPath = path.join(tmp.path, "a.txt") + const bPath = path.join(tmp.path, "b.txt") + const sequence = items.flatMap((part) => { + if (part.type === "text") { + if (part.text.includes(aPath)) return ["input:a"] + if (part.text.includes(bPath)) return ["input:b"] + if (part.text.includes("00001| 28")) return ["output:a"] + if (part.text.includes("00001| 42")) return ["output:b"] + return [] + } + if (part.type === "file") { + if (part.filename === "a.txt") return ["file:a"] + if (part.filename === "b.txt") return ["file:b"] + } + return [] + }) + + expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"]) + + await Session.remove(session.id) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 35d917f47682..bc8a42a73f9d 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -151,6 +151,26 @@ describe("tool.bash permissions", () => { }) }) + test("throws on invalid workdir", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + await expect( + bash.execute( + { + command: "ls", + workdir: path.join(tmp.path, "missing"), + description: "List missing directory", + }, + ctx, + ), + ).rejects.toThrow("Invalid working directory") + }, + }) + }) + test("asks for external_directory permission when file arg is outside project", async () => { await using outerTmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/tool/diagnostics-paths.test.ts b/packages/opencode/test/tool/diagnostics-paths.test.ts new file mode 100644 index 000000000000..dd0e8eca1075 --- /dev/null +++ b/packages/opencode/test/tool/diagnostics-paths.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { toPosix } from "@opencode-ai/util/path" +import { Instance } from "../../src/project/instance" +import { Filesystem } from "../../src/util/filesystem" +import { tmpdir } from "../fixture/fixture" +import { FileTime } from "../../src/file/time" +import { Log } from "../../src/util/log" +import { DiagnosticSeverity } from "vscode-languageserver-types" +import type { Diagnostic } from "vscode-languageserver-types" + +Log.init({ print: false }) + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +let map: Record = {} + +async function withLspMock(fn: () => Promise) { + const mod = await import("../../src/lsp") + const originalDiagnostics = mod.LSP.diagnostics + const originalTouch = mod.LSP.touchFile + const originalPretty = mod.LSP.Diagnostic.pretty + mod.LSP.diagnostics = async () => map + mod.LSP.touchFile = async () => {} + mod.LSP.Diagnostic.pretty = (d: Diagnostic) => d.message + try { + await fn() + } finally { + mod.LSP.diagnostics = originalDiagnostics + mod.LSP.touchFile = originalTouch + mod.LSP.Diagnostic.pretty = originalPretty + } +} + +describe("tool diagnostics key normalization", () => { + test("accepts posix diagnostics keys", async () => { + await using tmp = await tmpdir({ + git: true, + config: { lsp: false }, + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "hello") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await withLspMock(async () => { + const { EditTool } = await import("../../src/tool/edit") + const file = path.join(tmp.path, "a.txt") + const posix = toPosix(file) + const normalized = Filesystem.normalizePath(file) + map = { + [posix]: [ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + message: "issue", + severity: DiagnosticSeverity.Error, + }, + ], + } + + FileTime.read(ctx.sessionID, posix) + const edit = await EditTool.init() + const result = await edit.execute( + { + filePath: file, + oldString: "hello", + newString: "world", + }, + ctx, + ) + + expect(result.output).toContain("LSP errors detected") + if (normalized !== posix) { + expect(result.output).toContain("issue") + } + }) + }, + }) + }) + + test("windows: accepts canonical diagnostics keys", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir({ + git: true, + config: { lsp: false }, + init: async (dir) => { + await Bun.write(path.join(dir, "b.txt"), "old") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await withLspMock(async () => { + const { WriteTool } = await import("../../src/tool/write") + const file = path.join(tmp.path, "b.txt") + const raw = file.toUpperCase() + const input = toPosix(raw) + const canonical = Filesystem.normalizePath(input) + map = { + [canonical]: [ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + message: "issue", + severity: DiagnosticSeverity.Error, + }, + ], + } + + FileTime.read(ctx.sessionID, input) + const write = await WriteTool.init() + const result = await write.execute( + { + filePath: raw, + content: "new", + }, + ctx, + ) + + expect(result.output).toContain("LSP errors detected") + expect(result.output).toContain("issue") + }) + }, + }) + }) +}) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 7aad01acea38..7be5ef6544d0 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, toPosix } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" @@ -64,7 +64,8 @@ function getDiagnostics( filePath: string | undefined, ): Diagnostic[] { if (!diagnosticsByFile || !filePath) return [] - const diagnostics = diagnosticsByFile[filePath] ?? [] + const normalized = toPosix(filePath) + const diagnostics = diagnosticsByFile[normalized] ?? diagnosticsByFile[filePath] ?? [] return diagnostics.filter((d) => d.severity === 1).slice(0, 3) } diff --git a/packages/ui/src/custom-elements.d.ts b/packages/ui/src/custom-elements.d.ts index 49ec4449fa20..525c6dd6e313 100644 --- a/packages/ui/src/custom-elements.d.ts +++ b/packages/ui/src/custom-elements.d.ts @@ -1,17 +1 @@ -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 {} +export * from "../../ui/src/custom-elements.d.ts" From 2f59476e80748e80acb65070a469b04a03e9d067 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:11:29 +1000 Subject: [PATCH 37/57] fix(ci): stabilize test hooks --- packages/app/src/context/command.tsx | 1 + packages/opencode/test/fixture/fixture.ts | 12 +++++++++++- packages/opencode/test/session/prompt.test.ts | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 791569584004..ec1cb2f10d12 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -272,6 +272,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return if (suspended() || dialog.active) return const sig = signatureFromEvent(event) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 03b548378860..84e59b002eff 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -28,7 +28,17 @@ export async function tmpdir(options?: TmpDirOptions) { stderr: init.stderr.toString(), }) } - const commit = await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet().nothrow() + const commit = await $`git commit --allow-empty -m "root commit ${dirpath}"` + .cwd(dirpath) + .env({ + ...process.env, + GIT_AUTHOR_NAME: "opencode", + GIT_AUTHOR_EMAIL: "opencode@local", + GIT_COMMITTER_NAME: "opencode", + GIT_COMMITTER_EMAIL: "opencode@local", + }) + .quiet() + .nothrow() if (commit.exitCode !== 0) { console.error("git commit failed", { dirpath, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 7821c19e3b74..1d412533919d 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -28,7 +28,7 @@ mock.module("../../src/plugin/index", () => ({ list: async () => [], load: async () => {}, reload: async () => {}, - trigger: async () => {}, + trigger: async (_name: string, _input: unknown, output: unknown) => output, }, })) From bc08548064fdcc88b1ac0ebd4c22ae34c03934c1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:32:19 +1000 Subject: [PATCH 38/57] test(plugin): stabilize auth override --- .../test/plugin/auth-override.test.ts | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index d8f8ea4551b6..7f0cf32154f9 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" +import { pathToFileURL } from "url" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { ProviderAuth } from "../../src/provider/auth" @@ -30,15 +31,27 @@ describe("plugin.auth-override", () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const methods = await ProviderAuth.methods() - const copilot = methods["github-copilot"] - expect(copilot).toBeDefined() - expect(copilot.length).toBe(1) - expect(copilot[0].label).toBe("Test Override Auth") - }, - }) + const originalContent = process.env["OPENCODE_CONFIG_CONTENT"] + const pluginUrl = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href + process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ plugin: [pluginUrl] }) + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const methods = await ProviderAuth.methods() + const copilot = methods["github-copilot"] + expect(copilot).toBeDefined() + expect(copilot.length).toBe(1) + expect(copilot[0].label).toBe("Test Override Auth") + }, + }) + } finally { + if (originalContent === undefined) { + delete process.env["OPENCODE_CONFIG_CONTENT"] + } else { + process.env["OPENCODE_CONFIG_CONTENT"] = originalContent + } + } }, 30000) // Increased timeout for plugin installation }) From f334318ebb5fd77cc9531612f7df68481df2115e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:43:02 +1000 Subject: [PATCH 39/57] test(opencode): isolate plugin mock and git commits --- packages/opencode/test/session/prompt.test.ts | 6 +++++- packages/opencode/test/snapshot/snapshot.test.ts | 11 ++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 1d412533919d..6050e792f862 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, mock } from "bun:test" +import { describe, expect, test, mock, afterAll } from "bun:test" import { Session } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" import { MessageV2 } from "../../src/session/message-v2" @@ -34,6 +34,10 @@ mock.module("../../src/plugin/index", () => ({ Log.init({ print: false }) +afterAll(() => { + mock.restore() +}) + describe("SessionPrompt ordering", () => { test("keeps @file order with read output parts", async () => { await using tmp = await tmpdir({ diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 61b751317a77..9cd75ca86ac7 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -15,7 +15,16 @@ async function bootstrap() { await Bun.write(`${dir}/a.txt`, aContent) await Bun.write(`${dir}/b.txt`, bContent) await $`git add .`.cwd(dir).quiet() - await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init` + .cwd(dir) + .env({ + ...process.env, + GIT_AUTHOR_NAME: "opencode", + GIT_AUTHOR_EMAIL: "opencode@local", + GIT_COMMITTER_NAME: "opencode", + GIT_COMMITTER_EMAIL: "opencode@local", + }) + .quiet() return { aContent, bContent, From 7096c2f6bc74936e20f3a8e9fc6feddd9691f525 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:18:15 +1000 Subject: [PATCH 40/57] fix(opencode): normalize Windows paths for bash/lsp/session --- packages/opencode/src/lsp/index.ts | 5 +- .../opencode/src/server/routes/session.ts | 10 +++- packages/opencode/src/tool/bash.ts | 49 +++++++++++----- .../test/lsp/document-symbol-paths.test.ts | 50 +++++++++++++++++ .../opencode/test/server/session-list.test.ts | 29 ++++++++++ packages/opencode/test/tool/bash.test.ts | 56 +++++++++++++++++++ 6 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 packages/opencode/test/lsp/document-symbol-paths.test.ts diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index a999f5bed40a..aa4a0a488e1a 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -4,7 +4,7 @@ import { Log } from "../util/log" import { LSPClient } from "./client" import path from "@/util/path" import { Filesystem } from "@/util/filesystem" -import { pathToFileURL } from "url" +import { fileURLToPath, pathToFileURL } from "url" import { LSPServer } from "./server" import z from "zod" import { Config } from "../config/config" @@ -382,7 +382,8 @@ export namespace LSP { } export async function documentSymbol(uri: string) { - const file = new URL(uri).pathname + if (!uri.startsWith("file://")) return [] + const file = path.toPosix(fileURLToPath(uri)) return run(file, (client) => client.connection .sendRequest("textDocument/documentSymbol", { diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 991208bbd8a5..06c1d78e4afc 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -55,12 +55,18 @@ export const SessionRoutes = lazy(() => async (c) => { const query = c.req.valid("query") const term = query.search?.toLowerCase() - const directory = query.directory ? path.normalize(path.toPosix(query.directory)) : undefined + const normalize = (dir: string) => { + const normalized = path.normalize(path.toPosix(dir)) + if (/^[A-Z]:\/$/i.test(normalized) || normalized === "/") return normalized + return normalized.replace(/\/+$/, "") + } + + const directory = query.directory ? normalize(query.directory) : undefined const directoryKey = directory && process.platform === "win32" ? directory.toLowerCase() : directory const sessions: Session.Info[] = [] for await (const session of Session.list()) { if (directoryKey !== undefined) { - const sessionDir = path.normalize(path.toPosix(session.directory)) + const sessionDir = normalize(session.directory) const sessionKey = process.platform === "win32" ? sessionDir.toLowerCase() : sessionDir if (sessionKey !== directoryKey) continue } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 7a6f9429f446..454cb3263416 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -8,11 +8,11 @@ import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" import { Language } from "web-tree-sitter" -import { $ } from "bun" import { Filesystem } from "@/util/filesystem" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" import { Shell } from "@/shell/shell" +import fs from "fs/promises" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" @@ -28,6 +28,29 @@ export function externalDirectoryGlob(target: string, kind: "file" | "directory" return path.join(dir, "*") } +const unquote = (input: string) => { + if (input.length < 2) return input + const head = input.at(0) + const tail = input.at(-1) + if (!head || !tail) return input + if (head === "'" && tail === "'") return input.slice(1, -1) + if (head === '"' && tail === '"') return input.slice(1, -1) + return input +} + +const resolveArgPath = async (cwd: string, raw: string) => { + const arg = path.toPosix(unquote(raw)) + if (!arg) return + if (arg.startsWith("-")) return + if (arg.includes("*") || arg.includes("?") || arg.includes("$")) return + + const resolved = path.resolve(cwd, arg.startsWith("file://") ? path.toPosix(fileURLToPath(arg)) : arg) + return fs + .realpath(resolved) + .then((real) => Filesystem.normalizePath(real)) + .catch(() => Filesystem.normalizePath(resolved)) +} + const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset @@ -81,7 +104,11 @@ export const BashTool = Tool.define("bash", async () => { ), }), async execute(params, ctx) { - const cwd = path.resolve(params.workdir || Instance.directory) + const cwd = (() => { + if (!params.workdir) return Instance.directory + const input = path.toPosix(params.workdir) + return path.isAbsolute(input) ? path.resolve(input) : path.resolve(Instance.directory, input) + })() if (!(await Filesystem.isDir(cwd))) { throw new Error(`Invalid working directory: ${cwd}`) } @@ -124,19 +151,13 @@ 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()) + const resolved = await resolveArgPath(cwd, arg) log.info("resolved path", { arg, resolved }) - if (resolved) { - const normalized = path.toPosix(resolved) - if (Instance.containsPath(normalized)) continue - const isDir = await Filesystem.isDir(normalized) - directories.add(externalDirectoryGlob(normalized, isDir ? "directory" : "file")) - } + if (!resolved) continue + const normalized = path.toPosix(resolved) + if (Instance.containsPath(normalized)) continue + const isDir = await Filesystem.isDir(normalized) + directories.add(externalDirectoryGlob(normalized, isDir ? "directory" : "file")) } } diff --git a/packages/opencode/test/lsp/document-symbol-paths.test.ts b/packages/opencode/test/lsp/document-symbol-paths.test.ts new file mode 100644 index 000000000000..85b21f4cbbb0 --- /dev/null +++ b/packages/opencode/test/lsp/document-symbol-paths.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test, mock } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +let seen: string | undefined + +mock.module("../../src/lsp/server", () => ({ + LSPServer: { + Fake: { + id: "fake", + extensions: [".fake"], + root: async (file: string) => { + seen = file + return process.cwd() + }, + spawn: async () => undefined, + }, + }, +})) + +describe("LSP documentSymbol URI path normalization", () => { + test("windows: converts file:// URI to drive-letter path", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "a.fake"), "x") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + seen = undefined + const { LSP } = await import("../../src/lsp") + const uri = pathToFileURL(path.join(tmp.path, "a.fake")).href + await LSP.documentSymbol(uri) + expect(seen).toBeTruthy() + expect(seen!).toMatch(/^[a-zA-Z]:\//) + expect(seen!).not.toMatch(/^\/[a-zA-Z]:\//) + }, + }) + }) +}) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 623c16a8114f..c6b669549146 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -36,4 +36,33 @@ describe("session.list", () => { }, }) }) + + test("filters by directory with trailing separator", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + + const first = await Session.create({}) + + const otherDir = path.join(projectRoot, "..", "__session_list_other_2") + const second = await Instance.provide({ + directory: otherDir, + fn: async () => Session.create({}), + }) + + const directory = projectRoot.endsWith(path.sep) ? projectRoot : projectRoot + path.sep + const response = await app.request(`/session?directory=${encodeURIComponent(directory)}`) + expect(response.status).toBe(200) + + const body = (await response.json()) as unknown[] + const ids = body + .map((s) => (typeof s === "object" && s && "id" in s ? (s as { id: string }).id : undefined)) + .filter((x): x is string => typeof x === "string") + + expect(ids).toContain(first.id) + expect(ids).not.toContain(second.id) + }, + }) + }) }) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index bc8a42a73f9d..6dd3d6d1c74f 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import os from "os" import path from "path" +import fs from "fs/promises" import { toPosix } from "@opencode-ai/util/path" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" @@ -42,6 +43,28 @@ describe("tool.bash", () => { }) describe("tool.bash permissions", () => { + test("resolves relative workdir against Instance.directory", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.mkdir(path.join(tmp.path, "inner")) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { + command: "echo hi", + workdir: "inner", + description: "Echo in inner", + }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + expect(result.output).toContain("hi") + }, + }) + }) + test("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -123,6 +146,39 @@ describe("tool.bash permissions", () => { }) }) + test("asks for external_directory permission even when PATH is broken", async () => { + const before = process.env.PATH + process.env.PATH = process.platform === "win32" ? "Z:\\nope" : "/nope" + + try { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "cd ../", + description: "Change to parent directory", + }, + testCtx, + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + }, + }) + } finally { + process.env.PATH = before + } + }) + test("asks for external_directory permission when workdir is outside project", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ From 47fe7d0590d4711275d5c2fb595f87591a60ea4e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:33:12 +1000 Subject: [PATCH 41/57] test(opencode): expect posix skill paths --- packages/opencode/test/tool/skill.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index d5057ba9e7f4..7e29a22cdcc2 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" +import { toPosix } from "@opencode-ai/util/path" import type { PermissionNext } from "../../src/permission/next" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" @@ -91,8 +92,8 @@ Use this skill. } const result = await tool.execute({ name: "tool-skill" }, ctx) - const dir = path.join(tmp.path, ".opencode", "skill", "tool-skill") - const file = path.resolve(dir, "scripts", "demo.txt") + const dir = toPosix(path.join(tmp.path, ".opencode", "skill", "tool-skill")) + const file = toPosix(path.resolve(dir, "scripts", "demo.txt")) expect(requests.length).toBe(1) expect(requests[0].permission).toBe("skill") From 74a614f847e740f5047a6c836779110a5ef5eb12 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:53:14 +1000 Subject: [PATCH 42/57] ci(test): align windows e2e setup with linux --- .github/workflows/test.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c682e2fa449e..46d257b7090f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,8 +18,6 @@ jobs: playwright: bunx playwright install --with-deps workdir: . command: | - git config --global user.email "bot@opencode.ai" - git config --global user.name "opencode" bun turbo test - name: windows host: windows-latest @@ -27,7 +25,7 @@ jobs: workdir: . command: | bun --cwd packages/opencode test - bun --cwd packages/app test:e2e:local + bun --cwd packages/app test:e2e runs-on: ${{ matrix.settings.host }} defaults: run: @@ -41,6 +39,11 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun + - name: Configure git user + run: | + git config --global user.email "bot@opencode.ai" + git config --global user.name "opencode" + - name: Install Playwright browsers working-directory: packages/app run: ${{ matrix.settings.playwright }} @@ -64,7 +67,6 @@ jobs: fi - name: Seed opencode data - if: matrix.settings.name != 'windows' working-directory: packages/opencode run: bun script/seed-e2e.ts env: @@ -83,7 +85,6 @@ jobs: OPENCODE_E2E_MODEL: "opencode/gpt-5-nano" - name: Run opencode server - if: matrix.settings.name != 'windows' working-directory: packages/opencode run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 & env: @@ -99,7 +100,6 @@ jobs: OPENCODE_CLIENT: "app" - name: Wait for opencode server - if: matrix.settings.name != 'windows' run: | for i in {1..120}; do curl -fsS "http://127.0.0.1:4096/global/health" > /dev/null && exit 0 From 0596b8388e7708bd54a82bf846f986d168c3da5c Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:55:36 +1000 Subject: [PATCH 43/57] ci(test): run turbo tests on windows --- .github/workflows/test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 46d257b7090f..cc3c37935391 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,8 +24,7 @@ jobs: playwright: bunx playwright install workdir: . command: | - bun --cwd packages/opencode test - bun --cwd packages/app test:e2e + bun turbo test runs-on: ${{ matrix.settings.host }} defaults: run: From dc027bc826cfb0ae805291f01f0bc0a9c49688f0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:05:34 +1000 Subject: [PATCH 44/57] fix(sdk): run build script with bun --- packages/sdk/js/package.json | 2 +- packages/sdk/js/script/build.ts | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index f8a45019c36c..0736d412cda9 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -6,7 +6,7 @@ "license": "MIT", "scripts": { "typecheck": "tsgo --noEmit", - "build": "./script/build.ts" + "build": "bun ./script/build.ts" }, "exports": { ".": "./src/index.ts", diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 7568c54b0f2a..a6dbd70300c5 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -1,14 +1,16 @@ #!/usr/bin/env bun - -const dir = new URL("..", import.meta.url).pathname -process.chdir(dir) - import { $ } from "bun" +import fs from "fs/promises" import path from "path" +import { fileURLToPath } from "url" import { createClient } from "@hey-api/openapi-ts" -await $`bun dev generate > ${dir}/openapi.json`.cwd(path.resolve(dir, "../../opencode")) +const dir = fileURLToPath(new URL("..", import.meta.url)) +process.chdir(dir) + +const openapi = await $`bun dev generate`.cwd(path.resolve(dir, "../../opencode")).text() +await fs.writeFile(path.join(dir, "openapi.json"), openapi) await createClient({ input: "./openapi.json", @@ -39,6 +41,6 @@ await createClient({ await $`bun prettier --write src/gen` await $`bun prettier --write src/v2` -await $`rm -rf dist` +await fs.rm(path.join(dir, "dist"), { recursive: true, force: true }) await $`bun tsc` -await $`rm openapi.json` +await fs.rm(path.join(dir, "openapi.json"), { force: true }) From 31525958146d4c15815107a3d05da292a7a25f8f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:18:56 +1000 Subject: [PATCH 45/57] fix desktop and other things --- packages/app/src/components/dialog-select-directory.tsx | 4 ++-- packages/app/src/pages/layout.tsx | 4 ++-- packages/app/src/pages/session.tsx | 3 ++- packages/opencode/src/file/index.ts | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 6e7af3d902d8..dc01347c0e93 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -2,7 +2,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename, toPosix } from "@opencode-ai/util/path" import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" @@ -58,7 +58,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { } function normalize(input: string) { - const v = input.replaceAll("\\", "/") + const v = toPosix(input) if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") return v.replace(/\/+/g, "/") } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 46c9c9154ffd..5e77f30e0501 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -36,7 +36,7 @@ import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" import { Dialog } from "@opencode-ai/ui/dialog" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename, toPosix } from "@opencode-ai/util/path" import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" @@ -595,7 +595,7 @@ export default function Layout(props: ParentProps) { ), ) - const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") + const workspaceKey = (directory: string) => toPosix(directory).replace(/\/+$/, "") const workspaceName = (directory: string, projectId?: string, branch?: string) => { const key = workspaceKey(directory) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 29fed369e314..527b3b493866 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -41,6 +41,7 @@ import { useLayout } from "@/context/layout" import { Terminal } from "@/components/terminal" import { checksum, base64Encode } from "@opencode-ai/util/encode" import { findLast } from "@opencode-ai/util/array" +import { toPosix } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import FileTree from "@/components/file-tree" @@ -495,7 +496,7 @@ export default function Page() { return "mix" as const } - const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") + const normalize = (p: string) => toPosix(p).replace(/\/+$/, "") const out = new Map() for (const diff of diffs()) { diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 01c1851e66d4..4797e549484e 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -542,7 +542,7 @@ export namespace File { } export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - const query = input.query.trim() + const query = path.toPosix(input.query.trim()) const limit = input.limit ?? 100 const kind = input.type ?? (input.dirs === false ? "file" : "all") log.info("search", { query, kind }) From bf9d9c19e980f3e4e7bdea681099f3dbd3a49e41 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:41:26 +1000 Subject: [PATCH 46/57] fix(ui): keep file search results visible on Windows --- packages/app/src/components/dialog-select-file.tsx | 3 ++- packages/app/src/components/prompt-input.tsx | 3 ++- packages/ui/src/hooks/use-filtered-list.tsx | 14 ++++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 167f211953a7..ee8fb2e19154 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -5,7 +5,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" import { base64Encode } from "@opencode-ai/util/encode" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename, toPosix } from "@opencode-ai/util/path" import { useNavigate, useParams } from "@solidjs/router" import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybind, useCommand, type CommandOption } from "@/context/command" @@ -326,6 +326,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil emptyMessage={language.t("palette.empty")} loadingMessage={language.t("common.loading")} items={items} + normalizeFilter={toPosix} key={(item) => item.id} filterKeys={["title", "description", "category"]} groupBy={grouped() ? (item) => item.category : () => ""} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 6b568e9160b9..f48d04732334 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -39,7 +39,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import { getDirectory, getFilename, getFilenameTruncated, toPosix } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ModelSelectorPopover } from "@/components/dialog-select-model" @@ -482,6 +482,7 @@ export const PromptInput: Component = (props) => { .map((path) => ({ type: "file", path, display: path })) return [...agents, ...pinned, ...fileOptions] }, + normalizeFilter: toPosix, key: atKey, filterKeys: ["display"], groupBy: (item) => { diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index 2d4e2bdd1aae..a0a5f4cf9485 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -14,6 +14,7 @@ export interface FilteredListProps { sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number onSelect?: (value: T | undefined, index: number) => void noInitialSelection?: boolean + normalizeFilter?: (value: string) => string } export function useFilteredList(props: FilteredListProps) { @@ -22,11 +23,16 @@ export function useFilteredList(props: FilteredListProps) { type Group = { category: string; items: [T, ...T[]] } const empty: Group[] = [] + const normalize = (value: string) => props.normalizeFilter?.(value) ?? value + const [grouped, { refetch }] = createResource( - () => ({ - filter: store.filter, - items: typeof props.items === "function" ? props.items(store.filter) : props.items, - }), + () => { + const filter = normalize(store.filter) + return { + filter, + items: typeof props.items === "function" ? props.items(filter) : props.items, + } + }, async ({ filter, items }) => { const query = filter ?? "" const needle = query.toLowerCase() From 1e483003019ab2cba295e31f55194d315a492a4f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:18:37 +1000 Subject: [PATCH 47/57] clean --- .../src/cli/cmd/tui/util/clipboard.ts | 15 ----------- .../test/util/no-native-path-imports.test.ts | 26 ++++++++++++++++--- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 4be40791151f..8bbda42c8e8c 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -7,21 +7,6 @@ import path from "@/util/path" const rendererRef = { current: undefined as CliRenderer | undefined } -/** - * Writes text to clipboard via OSC 52 escape sequence. - * This allows clipboard operations to work over SSH by having - * the terminal emulator handle the clipboard locally. - */ -function writeOsc52(text: string): void { - if (!process.stdout.isTTY) return - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - // tmux and screen require DCS passthrough wrapping - const passthrough = process.env["TMUX"] || process.env["STY"] - const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - process.stdout.write(sequence) -} - export namespace Clipboard { export interface Content { data: string diff --git a/packages/opencode/test/util/no-native-path-imports.test.ts b/packages/opencode/test/util/no-native-path-imports.test.ts index 320e4c3db374..f4e1a2d56874 100644 --- a/packages/opencode/test/util/no-native-path-imports.test.ts +++ b/packages/opencode/test/util/no-native-path-imports.test.ts @@ -2,11 +2,25 @@ import { describe, expect, test } from "bun:test" import nodePath from "node:path" describe("path imports", () => { + // Canonical rule: paths are POSIX/Bash-compatible everywhere. + // Why: Windows backslashes caused + // - UI file search/filter mismatches (client query used "\" while indexed paths used "/") + // - server/client path comparisons to fail (drive/UNC edge cases) + // - bash tool arg parsing to mis-handle paths ("\" is an escape in bash) + // Enforcement via import boundaries: + // - opencode/src must use @/util/path (Node wrapper + /tmp mapping) + // - app/ui/web/sdk/desktop/plugin must use @opencode-ai/util/path (browser-safe) + // - native path/node:path imports are disallowed everywhere test("repo source avoids native path imports", async () => { + const base = nodePath.join(import.meta.dir, "..", "..", "..") const roots = [ { name: "opencode", dir: nodePath.join(import.meta.dir, "..", "..", "src"), allow: ["/src/util/path.ts"] }, - { name: "app", dir: nodePath.join(import.meta.dir, "..", "..", "..", "app", "src"), allow: [] }, - { name: "desktop", dir: nodePath.join(import.meta.dir, "..", "..", "..", "desktop", "src"), allow: [] }, + { name: "app", dir: nodePath.join(base, "app", "src"), allow: [] }, + { name: "desktop", dir: nodePath.join(base, "desktop", "src"), allow: [] }, + { name: "plugin", dir: nodePath.join(base, "plugin", "src"), allow: [] }, + { name: "sdk", dir: nodePath.join(base, "sdk", "js", "src"), allow: [] }, + { name: "ui", dir: nodePath.join(base, "ui", "src"), allow: [] }, + { name: "web", dir: nodePath.join(base, "web", "src"), allow: [] }, ] const glob = new Bun.Glob("**/*.{ts,tsx}") const hits: string[] = [] @@ -24,7 +38,13 @@ describe("path imports", () => { const direct = /^\s*import\s+.*\s+from\s+["'](?:node:)?path["']\s*$/m.test(content) const named = /^\s*import\s+\{[^}]*\}\s+from\s+["'](?:node:)?path["']\s*$/m.test(content) - if (direct || named) hits.push(`${root.name}:${normalized}`) + if (direct || named) hits.push(`${root.name}:${normalized}:path`) + + const serverPath = /^\s*import\s+.*\s+from\s+["']@\/util\/path["']\s*$/m.test(content) + const clientPath = /^\s*import\s+.*\s+from\s+["']@opencode-ai\/util\/path["']\s*$/m.test(content) + + if (root.name === "opencode" && clientPath) hits.push(`${root.name}:${normalized}:@opencode-ai/util/path`) + if (root.name !== "opencode" && serverPath) hits.push(`${root.name}:${normalized}:@/util/path`) } } From d4382065e54e41fef31055e7c2713da8c46c5c68 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:30:39 +1000 Subject: [PATCH 48/57] test(opencode): prevent Windows path mismatches in instruction tests --- .../opencode/test/session/instruction.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index be4567e460d7..ec8de4cdeee3 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -62,7 +62,7 @@ describe("InstructionPrompt.resolve", () => { fn: async () => { const filepath = path.join(tmp.path, "subdir", "AGENTS.md") const system = await InstructionPrompt.systemPaths() - expect(system.has(filepath)).toBe(false) + expect(system.has(toPosix(filepath))).toBe(false) const results = await InstructionPrompt.resolve([], filepath, "test-message-2") expect(results).toEqual([]) @@ -81,9 +81,9 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => { afterEach(() => { if (originalConfigDir === undefined) { delete process.env["OPENCODE_CONFIG_DIR"] - } else { - process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir + return } + process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir }) test("prefers OPENCODE_CONFIG_DIR AGENTS.md over global when both exist", async () => { @@ -108,8 +108,8 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => { directory: projectTmp.path, fn: async () => { const paths = await InstructionPrompt.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) + expect(paths.has(toPosix(path.join(profileTmp.path, "AGENTS.md")))).toBe(true) + expect(paths.has(toPosix(path.join(globalTmp.path, "AGENTS.md")))).toBe(false) }, }) } finally { @@ -135,8 +135,8 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => { directory: projectTmp.path, fn: async () => { const paths = await InstructionPrompt.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(toPosix(path.join(profileTmp.path, "AGENTS.md")))).toBe(false) + expect(paths.has(toPosix(path.join(globalTmp.path, "AGENTS.md")))).toBe(true) }, }) } finally { @@ -161,7 +161,7 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => { directory: projectTmp.path, fn: async () => { const paths = await InstructionPrompt.systemPaths() - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(toPosix(path.join(globalTmp.path, "AGENTS.md")))).toBe(true) }, }) } finally { From f9ee5a2c5122dc08bf910eaecf49dd36c8be0bbf Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:49:52 +1000 Subject: [PATCH 49/57] consolidate + pit of success --- packages/app/src/pages/layout.tsx | 4 +-- packages/app/src/pages/session.tsx | 6 ++-- packages/app/src/utils/worktree.ts | 17 ++++------ .../opencode/src/server/routes/session.ts | 12 ++----- packages/opencode/src/util/path.ts | 9 +++-- packages/util/src/path.ts | 34 +++++++++++++++++++ 6 files changed, 54 insertions(+), 28 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5e77f30e0501..534cf2215e14 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -36,7 +36,7 @@ import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" import { Dialog } from "@opencode-ai/ui/dialog" -import { getFilename, toPosix } from "@opencode-ai/util/path" +import { getFilename, normalizeDirectory } from "@opencode-ai/util/path" import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" @@ -595,7 +595,7 @@ export default function Layout(props: ParentProps) { ), ) - const workspaceKey = (directory: string) => toPosix(directory).replace(/\/+$/, "") + const workspaceKey = (directory: string) => normalizeDirectory(directory) const workspaceName = (directory: string, projectId?: string, branch?: string) => { const key = workspaceKey(directory) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 527b3b493866..28ab14b93c20 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -41,7 +41,7 @@ import { useLayout } from "@/context/layout" import { Terminal } from "@/components/terminal" import { checksum, base64Encode } from "@opencode-ai/util/encode" import { findLast } from "@opencode-ai/util/array" -import { toPosix } from "@opencode-ai/util/path" +import { normalizeDirectory } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import FileTree from "@/components/file-tree" @@ -496,11 +496,9 @@ export default function Page() { return "mix" as const } - const normalize = (p: string) => toPosix(p).replace(/\/+$/, "") - const out = new Map() for (const diff of diffs()) { - const file = normalize(diff.file) + const file = normalizeDirectory(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) diff --git a/packages/app/src/utils/worktree.ts b/packages/app/src/utils/worktree.ts index dc0f035ade09..3e3d3c8ddcb1 100644 --- a/packages/app/src/utils/worktree.ts +++ b/packages/app/src/utils/worktree.ts @@ -1,10 +1,5 @@ import { toPosix } from "@opencode-ai/util/path" - -const normalize = (directory: string) => { - const normalized = toPosix(directory) - if (/^[A-Z]:\/$/i.test(normalized) || normalized === "/") return normalized - return normalized.replace(/\/+$/, "") -} +import { normalizeDirectory } from "@opencode-ai/util/path" type State = | { @@ -37,16 +32,16 @@ function deferred() { export const Worktree = { get(directory: string) { - return state.get(normalize(directory)) + return state.get(normalizeDirectory(directory)) }, pending(directory: string) { - const key = normalize(directory) + const key = normalizeDirectory(directory) const current = state.get(key) if (current && current.status !== "pending") return state.set(key, { status: "pending" }) }, ready(directory: string) { - const key = normalize(directory) + const key = normalizeDirectory(directory) const next = { status: "ready" } as const state.set(key, next) const waiter = waiters.get(key) @@ -55,7 +50,7 @@ export const Worktree = { waiter.resolve(next) }, failed(directory: string, message: string) { - const key = normalize(directory) + const key = normalizeDirectory(directory) const next = { status: "failed", message } as const state.set(key, next) const waiter = waiters.get(key) @@ -64,7 +59,7 @@ export const Worktree = { waiter.resolve(next) }, wait(directory: string) { - const key = normalize(directory) + const key = normalizeDirectory(directory) const current = state.get(key) if (current && current.status !== "pending") return Promise.resolve(current) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 06c1d78e4afc..f9acddf80395 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -16,7 +16,7 @@ import { Log } from "../../util/log" import { PermissionNext } from "@/permission/next" import { errors } from "../error" import { lazy } from "../../util/lazy" -import path from "@/util/path" +import { normalizeDirectory } from "@/util/path" const log = Log.create({ service: "server" }) @@ -55,18 +55,12 @@ export const SessionRoutes = lazy(() => async (c) => { const query = c.req.valid("query") const term = query.search?.toLowerCase() - const normalize = (dir: string) => { - const normalized = path.normalize(path.toPosix(dir)) - if (/^[A-Z]:\/$/i.test(normalized) || normalized === "/") return normalized - return normalized.replace(/\/+$/, "") - } - - const directory = query.directory ? normalize(query.directory) : undefined + const directory = query.directory ? normalizeDirectory(query.directory) : undefined const directoryKey = directory && process.platform === "win32" ? directory.toLowerCase() : directory const sessions: Session.Info[] = [] for await (const session of Session.list()) { if (directoryKey !== undefined) { - const sessionDir = normalize(session.directory) + const sessionDir = normalizeDirectory(session.directory) const sessionKey = process.platform === "win32" ? sessionDir.toLowerCase() : sessionDir if (sessionKey !== directoryKey) continue } diff --git a/packages/opencode/src/util/path.ts b/packages/opencode/src/util/path.ts index 37d025eab4b8..e118ab2a282b 100644 --- a/packages/opencode/src/util/path.ts +++ b/packages/opencode/src/util/path.ts @@ -1,7 +1,7 @@ import os from "os" import nodePath from "node:path" -import { toPosix as baseToPosix } from "@opencode-ai/util/path" +import { normalizeDirectory as baseNormalizeDirectory, toPosix as baseToPosix } from "@opencode-ai/util/path" const isWin = process.platform === "win32" @@ -19,7 +19,11 @@ function normalizeArgs(args: string[]) { return args.map((x) => toPosix(x)) } -export { toPosix } +function normalizeDirectory(input: string) { + return toPosix(baseNormalizeDirectory(input)) +} + +export { toPosix, normalizeDirectory } export default { ...nodePath, @@ -32,4 +36,5 @@ export default { isWin ? toPosix(nodePath.relative(toPosix(from), toPosix(to))) : nodePath.relative(from, to), toPosix, + normalizeDirectory, } diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index a3849c707365..817962c09b3e 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -1,3 +1,7 @@ +/** + * Returns the last segment of a path. + * Use for display labels from any path string (handles / and \\ separators). + */ export function getFilename(path: string | undefined) { if (!path) return "" const trimmed = path.replace(/[\/\\]+$/, "") @@ -11,6 +15,8 @@ const isWin = /** * Normalize Windows paths to be Bash/LLM friendly. + * Use before comparisons, search keys, or shell-facing paths to enforce + * POSIX separators and stable drive/UNC forms. * * - Forces '/' separators (C:/foo/bar) * - Converts MSYS/Cygwin/WSL roots (/c, /cygdrive/c, /mnt/c) -> C:/ @@ -38,6 +44,22 @@ export function toPosix(p: string) { return res } +/** + * Normalize a directory for identity/keys. + * Use when comparing or memoizing directories; trims trailing slashes + * but preserves roots like /, //, and C:/. + */ +export function normalizeDirectory(input: string) { + const normalized = toPosix(input) + if (!normalized) return normalized + if (normalized === "/" || normalized === "//" || /^[A-Za-z]:\/$/.test(normalized)) return normalized + return normalized.replace(/\/+$/, "") +} + +/** + * Returns the parent directory path with a trailing slash. + * Use for UI display or when you need a path prefix. + */ export function getDirectory(path: string | undefined) { if (!path) return "" const trimmed = path.replace(/[\/\\]+$/, "") @@ -45,12 +67,20 @@ export function getDirectory(path: string | undefined) { return parts.slice(0, parts.length - 1).join("/") + "/" } +/** + * Returns the file extension without normalization. + * Use for UI or file type logic; returns "" when no extension. + */ export function getFileExtension(path: string | undefined) { if (!path) return "" const parts = path.split(".") return parts[parts.length - 1] } +/** + * Returns a filename shortened to maxLength, preserving extension. + * Use for UI labels where space is constrained. + */ export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) { const filename = getFilename(path) if (filename.length <= maxLength) return filename @@ -61,6 +91,10 @@ export function getFilenameTruncated(path: string | undefined, maxLength: number return filename.slice(0, available) + "…" + ext } +/** + * Truncate a string in the middle with an ellipsis. + * Use for long identifiers where both ends are meaningful. + */ export function truncateMiddle(text: string, maxLength: number = 20) { if (text.length <= maxLength) return text const available = maxLength - 1 // -1 for ellipsis From 5e0d3b71329b6c2a2da8421701f95460ced68c89 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:56:04 +1000 Subject: [PATCH 50/57] ci: align Windows tests with release runner --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cc3c37935391..4784715fde5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: command: | bun turbo test - name: windows - host: windows-latest + host: blacksmith-4vcpu-windows-2025 playwright: bunx playwright install workdir: . command: | From 922d9b499c288c2278b2c31d67447655aca775df Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:14:08 +1000 Subject: [PATCH 51/57] test(opencode): keep worktree matching case-insensitive on Windows --- packages/opencode/src/worktree/index.ts | 19 +++--- .../test/worktree/worktree-paths.test.ts | 59 +++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/worktree/worktree-paths.test.ts diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 9c186a2461c5..b97a45dd97ab 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -12,6 +12,7 @@ import { fn } from "../util/fn" import { Log } from "../util/log" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" +import { Filesystem } from "@/util/filesystem" export namespace Worktree { const log = Log.create({ service: "worktree" }) @@ -219,11 +220,15 @@ export namespace Worktree { return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n") } + function caseInsensitiveKey(value: string) { + if (process.platform !== "win32") return value + return value.toLowerCase() + } + async function canonical(input: string) { const abs = path.resolve(input) const real = await fs.realpath(abs).catch(() => abs) - const normalized = path.normalize(real) - return process.platform === "win32" ? normalized.toLowerCase() : normalized + return Filesystem.normalizePath(real) } async function candidate(root: string, base?: string) { @@ -381,7 +386,7 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const directory = await canonical(input.directory) + const directory = caseInsensitiveKey(await canonical(input.directory)) const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) if (list.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) @@ -407,7 +412,7 @@ export namespace Worktree { const entry = await (async () => { for (const item of entries) { if (!item.path) continue - const key = await canonical(item.path) + const key = caseInsensitiveKey(await canonical(item.path)) if (key === directory) return item } })() @@ -436,8 +441,8 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const directory = await canonical(input.directory) - const primary = await canonical(Instance.worktree) + const directory = caseInsensitiveKey(await canonical(input.directory)) + const primary = caseInsensitiveKey(await canonical(Instance.worktree)) if (directory === primary) { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } @@ -467,7 +472,7 @@ export namespace Worktree { const entry = await (async () => { for (const item of entries) { if (!item.path) continue - const key = await canonical(item.path) + const key = caseInsensitiveKey(await canonical(item.path)) if (key === directory) return item } })() diff --git a/packages/opencode/test/worktree/worktree-paths.test.ts b/packages/opencode/test/worktree/worktree-paths.test.ts new file mode 100644 index 000000000000..347bb8fb8755 --- /dev/null +++ b/packages/opencode/test/worktree/worktree-paths.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Worktree } from "../../src/worktree" +import { tmpdir } from "../fixture/fixture" + +function flipCase(value: string) { + return value.replace(/[A-Za-z]/g, (char) => (char === char.toLowerCase() ? char.toUpperCase() : char.toLowerCase())) +} + +async function exists(target: string) { + return fs + .stat(target) + .then(() => true) + .catch(() => false) +} + +describe("Worktree path matching", () => { + if (process.platform !== "win32") return + + test("remove matches worktrees case-insensitively", async () => { + await using tmp = await tmpdir({ git: true }) + const worktreePath = path.join( + tmp.path, + "..", + `worktree-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ) + const created = await $`git worktree add ${worktreePath} -b test-branch`.quiet().nothrow().cwd(tmp.path) + expect(created.exitCode).toBe(0) + + const variant = flipCase(worktreePath) + expect(variant).not.toBe(worktreePath) + + const removed = await Instance.provide({ + directory: tmp.path, + fn: async () => Worktree.remove({ directory: variant }), + }) + expect(removed).toBe(true) + expect(await exists(worktreePath)).toBe(false) + }) + + test("reset blocks primary workspace case-insensitively", async () => { + await using tmp = await tmpdir({ git: true }) + const variant = flipCase(tmp.path) + expect(variant).not.toBe(tmp.path) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(Worktree.reset({ directory: variant })).rejects.toMatchObject({ + name: "WorktreeResetFailedError", + data: { message: "Cannot reset the primary workspace" }, + }) + }, + }) + }) +}) From 66b0452e9eb60e2a2120734d0c5daf9e92a6ebe3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:42:40 +1000 Subject: [PATCH 52/57] fix(opencode): honor OPENCODE_CONFIG_CONTENT at runtime --- packages/opencode/src/flag/flag.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index b11058b34058..5a21e6d302f7 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -8,7 +8,7 @@ export namespace Flag { export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] export declare const OPENCODE_CONFIG_DIR: string | undefined - export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] + export declare const OPENCODE_CONFIG_CONTENT: string | undefined export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE") export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE") @@ -81,6 +81,17 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", { configurable: false, }) +// Dynamic getter for OPENCODE_CONFIG_CONTENT +// This must be evaluated at access time, not module load time, +// because tests and tooling may set this env var at runtime +Object.defineProperty(Flag, "OPENCODE_CONFIG_CONTENT", { + get() { + return process.env["OPENCODE_CONFIG_CONTENT"] + }, + enumerable: true, + configurable: false, +}) + // Dynamic getter for OPENCODE_CLIENT // This must be evaluated at access time, not module load time, // because some commands override the client at runtime From a811463b3f67a8e8f7c69268726abb4777a46228 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:55:31 +1000 Subject: [PATCH 53/57] test(opencode): avoid plugin mock leakage --- packages/opencode/test/session/prompt.test.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 6050e792f862..1408fec7e5ac 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -22,15 +22,10 @@ mock.module("../../src/bun/index", () => ({ }, })) -// Mock Plugin to avoid loading plugins during provider initialization -mock.module("../../src/plugin/index", () => ({ - Plugin: { - list: async () => [], - load: async () => {}, - reload: async () => {}, - trigger: async (_name: string, _input: unknown, output: unknown) => output, - }, -})) +// Mock built-in plugins to avoid module resolution failures +const mockPlugin = () => ({}) as any +mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin })) +mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin })) Log.init({ print: false }) From 77a1ef2f52e9624f2bad22b650e9e8a299a1d682 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:42:21 +1000 Subject: [PATCH 54/57] fix(opencode): accept absolute Windows paths in apply_patch --- packages/opencode/src/tool/apply_patch.ts | 10 ++++++++-- .../opencode/test/tool/apply_patch.test.ts | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 7cff6f336d6c..769657c5badf 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -57,8 +57,14 @@ export const ApplyPatchTool = Tool.define("apply_patch", { let totalDiff = "" + const resolvePatchPath = (input: string) => { + const normalized = path.toPosix(input) + if (path.isAbsolute(normalized)) return normalized + return path.resolve(Instance.directory, normalized) + } + for (const hunk of hunks) { - const filePath = path.resolve(Instance.directory, hunk.path) + const filePath = resolvePatchPath(hunk.path) await assertExternalDirectory(ctx, filePath) switch (hunk.type) { @@ -116,7 +122,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { if (change.removed) deletions += change.count || 0 } - const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined + const movePath = hunk.move_path ? resolvePatchPath(hunk.move_path) : undefined await assertExternalDirectory(ctx, movePath) fileChanges.push({ diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 39415621c178..65e24b6938ee 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -259,6 +259,26 @@ describe("tool.apply_patch freeform", () => { }) }) + test("accepts absolute Windows paths with backslashes", async () => { + if (process.platform !== "win32") return + + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "abs.txt") + await fs.writeFile(target, "before\n", "utf-8") + + const patchText = `*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch` + await execute({ patchText }, ctx) + + expect(await fs.readFile(target, "utf-8")).toBe("after\n") + }, + }) + }) + test("adds file overwriting existing file", async () => { await using fixture = await tmpdir() const { ctx } = makeCtx() From 04ac138297949d447dafa61255ab1f73908ebd02 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:02:13 +1000 Subject: [PATCH 55/57] Revert "fix(opencode): accept absolute Windows paths in apply_patch" This reverts commit 77a1ef2f52e9624f2bad22b650e9e8a299a1d682. --- packages/opencode/src/tool/apply_patch.ts | 10 ++-------- .../opencode/test/tool/apply_patch.test.ts | 20 ------------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 769657c5badf..7cff6f336d6c 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -57,14 +57,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", { let totalDiff = "" - const resolvePatchPath = (input: string) => { - const normalized = path.toPosix(input) - if (path.isAbsolute(normalized)) return normalized - return path.resolve(Instance.directory, normalized) - } - for (const hunk of hunks) { - const filePath = resolvePatchPath(hunk.path) + const filePath = path.resolve(Instance.directory, hunk.path) await assertExternalDirectory(ctx, filePath) switch (hunk.type) { @@ -122,7 +116,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { if (change.removed) deletions += change.count || 0 } - const movePath = hunk.move_path ? resolvePatchPath(hunk.move_path) : undefined + const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined await assertExternalDirectory(ctx, movePath) fileChanges.push({ diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 65e24b6938ee..39415621c178 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -259,26 +259,6 @@ describe("tool.apply_patch freeform", () => { }) }) - test("accepts absolute Windows paths with backslashes", async () => { - if (process.platform !== "win32") return - - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "abs.txt") - await fs.writeFile(target, "before\n", "utf-8") - - const patchText = `*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch` - await execute({ patchText }, ctx) - - expect(await fs.readFile(target, "utf-8")).toBe("after\n") - }, - }) - }) - test("adds file overwriting existing file", async () => { await using fixture = await tmpdir() const { ctx } = makeCtx() From ea7ca8da2097506b181e4dc71534303fb766f63e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:11:44 +1000 Subject: [PATCH 56/57] fix(app): normalize UI absolute paths --- packages/app/src/components/prompt-input.tsx | 10 +++++++--- packages/app/src/context/sync.tsx | 8 +++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f48d04732334..09890db91381 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -39,7 +39,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { getDirectory, getFilename, getFilenameTruncated, toPosix } from "@opencode-ai/util/path" +import { getDirectory, getFilename, getFilenameTruncated, normalizeDirectory, toPosix } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ModelSelectorPopover } from "@/components/dialog-select-model" @@ -1303,8 +1303,12 @@ export const PromptInput: Component = (props) => { } } - const toAbsolutePath = (path: string) => - path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/") + const toAbsolutePath = (input: string) => { + if (input.startsWith("/") || /^[a-zA-Z]:/.test(input)) return toPosix(input) + const base = normalizeDirectory(sessionDirectory) + const suffix = input.startsWith("/") ? input.slice(1) : input + return toPosix(`${base}/${suffix}`) + } const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[] const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[] diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 0c636524501b..cc9c1e0a113a 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -2,6 +2,7 @@ import { batch, createMemo } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" +import { normalizeDirectory, toPosix } from "@opencode-ai/util/path" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" import { useSDK } from "./sdk" @@ -21,7 +22,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ type Setter = Child[1] const current = createMemo(() => globalSync.child(sdk.directory)) - const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") + const absolute = (input: string) => { + if (input.startsWith("/") || /^[a-zA-Z]:/.test(input)) return toPosix(input) + const base = normalizeDirectory(current()[0].path.directory) + const suffix = input.startsWith("/") ? input.slice(1) : input + return toPosix(`${base}/${suffix}`) + } const chunk = 400 const inflight = new Map>() const inflightDiff = new Map>() From 5c7d7e2318cdb7e05c6344723f6542eb4d288a52 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:21:57 +1000 Subject: [PATCH 57/57] fix(opencode): preserve file mention order --- packages/opencode/src/session/prompt.ts | 63 ++++++++++++------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7752c24f950d..c22ad3fcec08 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -189,45 +189,42 @@ export namespace SessionPrompt { ] const files = ConfigMarkdown.files(template) const seen = new Set() - await Promise.all( - files.map(async (match) => { - const name = match[1] - if (seen.has(name)) return - seen.add(name) - const filepath = name.startsWith("~/") - ? path.join(os.homedir(), name.slice(2)) - : path.resolve(Instance.worktree, name) - - const stats = await fs.stat(filepath).catch(() => undefined) - if (!stats) { - const agent = await Agent.get(name) - if (agent) { - parts.push({ - type: "agent", - name: agent.name, - }) - } - return - } - - if (stats.isDirectory()) { - parts.push({ - type: "file", - url: `file://${filepath}`, - filename: name, - mime: "application/x-directory", - }) - return - } + for (const match of files) { + const name = match[1] + if (seen.has(name)) continue + seen.add(name) + const filepath = name.startsWith("~/") + ? path.join(os.homedir(), name.slice(2)) + : path.resolve(Instance.worktree, name) + + const stats = await fs.stat(filepath).catch(() => undefined) + if (!stats) { + const agent = await Agent.get(name) + if (!agent) continue + parts.push({ + type: "agent", + name: agent.name, + }) + continue + } + if (stats.isDirectory()) { parts.push({ type: "file", url: `file://${filepath}`, filename: name, - mime: "text/plain", + mime: "application/x-directory", }) - }), - ) + continue + } + + parts.push({ + type: "file", + url: `file://${filepath}`, + filename: name, + mime: "text/plain", + }) + } return parts }