From 85407d12a8006a0cb23a0aba0ae8b0a7292988b0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 4 Feb 2026 22:23:01 -0500 Subject: [PATCH] fix: wait for dependencies before loading custom tools and plugins Fixes a race condition where custom tools with external dependencies in their package.json would crash on import because dependencies weren't installed yet. Changes: - Config.state() now collects dependency installation promises in a deps array - Added Config.waitForDependencies() to await all installations - ToolRegistry calls waitForDependencies() before loading custom tools - Plugin.list() calls waitForDependencies() before loading plugins - Added test to verify tools with external deps (cowsay) load successfully --- packages/opencode/src/config/config.ts | 19 ++++++-- packages/opencode/src/plugin/index.ts | 1 + packages/opencode/src/tool/registry.ts | 21 ++++----- packages/opencode/test/tool/registry.test.ts | 46 ++++++++++++++++++++ 4 files changed, 71 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7c7f1cc43e6a..dfb86dbe26f3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -30,6 +30,7 @@ import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" import { PackageRegistry } from "@/bun/registry" import { proxied } from "@/util/proxied" +import { iife } from "@/util/iife" export namespace Config { const log = Log.create({ service: "config" }) @@ -144,6 +145,8 @@ export namespace Config { log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) } + const deps = [] + for (const dir of unique(directories)) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.jsonc", "opencode.json"]) { @@ -156,10 +159,12 @@ export namespace Config { } } - const shouldInstall = await needsInstall(dir) - if (shouldInstall) { - await installDependencies(dir) - } + deps.push( + iife(async () => { + const shouldInstall = await needsInstall(dir) + if (shouldInstall) await installDependencies(dir) + }), + ) result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir)) @@ -233,9 +238,15 @@ export namespace Config { return { config: result, directories, + deps, } }) + export async function waitForDependencies() { + const deps = await state().then((x) => x.deps) + await Promise.all(deps) + } + export async function installDependencies(dir: string) { const pkg = path.join(dir, "package.json") const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 6032935f8480..4e5776e51d1f 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -44,6 +44,7 @@ export namespace Plugin { } const plugins = [...(config.plugin ?? [])] + if (plugins.length) await Config.waitForDependencies() if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { plugins.push(...BUILTIN) } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 3cb7715947be..5ed5a879b484 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -35,18 +35,15 @@ export namespace ToolRegistry { const custom = [] as Tool.Info[] const glob = new Bun.Glob("{tool,tools}/*.{js,ts}") - for (const dir of await Config.directories()) { - for await (const match of glob.scan({ - cwd: dir, - absolute: true, - followSymlinks: true, - dot: true, - })) { - const namespace = path.basename(match, path.extname(match)) - const mod = await import(match) - for (const [id, def] of Object.entries(mod)) { - custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) - } + const matches = await Config.directories().then((dirs) => + dirs.flatMap((dir) => [...glob.scanSync({ cwd: dir, absolute: true, followSymlinks: true, dot: true })]), + ) + if (matches.length) await Config.waitForDependencies() + for (const match of matches) { + const namespace = path.basename(match, path.extname(match)) + const mod = await import(match) + for (const [id, def] of Object.entries(mod)) { + custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index aea8b7088f4d..706a9e12caf9 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -73,4 +73,50 @@ describe("tool.registry", () => { }, }) }) + + test("loads tools with external dependencies without crashing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + + const toolsDir = path.join(opencodeDir, "tools") + await fs.mkdir(toolsDir, { recursive: true }) + + await Bun.write( + path.join(opencodeDir, "package.json"), + JSON.stringify({ + name: "custom-tools", + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", + }, + }), + ) + + await Bun.write( + path.join(toolsDir, "cowsay.ts"), + [ + "import { say } from 'cowsay'", + "export default {", + " description: 'tool that imports cowsay at top level',", + " args: { text: { type: 'string' } },", + " execute: async ({ text }: { text: string }) => {", + " return say({ text })", + " },", + "}", + "", + ].join("\n"), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + expect(ids).toContain("cowsay") + }, + }) + }) })