Przeglądaj źródła

Refactor plugin/config loading, add theme-only plugin package support (#20556)

Sebastian 5 miesięcy temu
rodzic
commit
f6fd43e574

+ 22 - 3
packages/opencode/specs/tui-plugins.md

@@ -10,6 +10,7 @@ Technical reference for the current TUI plugin system.
 - Package plugins can be installed from CLI or TUI.
 - v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
 - Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
+- npm packages can be TUI theme-only via `package.json["oc-themes"]` without a `./tui` entrypoint.
 
 ## TUI config
 
@@ -88,7 +89,8 @@ export default plugin
 - If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
 - For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
 - `package.json` `main` is only used for server plugin entrypoint resolution.
-- If a configured plugin has no target-specific entrypoint, it is skipped with a warning (not a load failure).
+- If a configured TUI package has no `./tui` entrypoint and no valid `oc-themes`, it is skipped with a warning (not a load failure).
+- If a configured TUI package has no `./tui` entrypoint but has valid `oc-themes`, runtime creates a no-op module record and still loads it for theme sync and plugin state.
 - If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
 - File/path plugins must export a non-empty `id`.
 - npm plugins may omit `id`; package `name` is used.
@@ -101,10 +103,18 @@ export default plugin
 
 ## Package manifest and install
 
-Install target detection is inferred from `package.json` entrypoints:
+Install target detection is inferred from `package.json` entrypoints and theme metadata:
 
 - `server` target when `exports["./server"]` exists or `main` is set.
 - `tui` target when `exports["./tui"]` exists.
+- `tui` target when `oc-themes` exists and resolves to a non-empty set of valid package-relative theme paths.
+
+`oc-themes` rules:
+
+- `oc-themes` is an array of relative paths.
+- Absolute paths and `file://` paths are rejected.
+- Resolved theme paths must stay inside the package directory.
+- Invalid `oc-themes` causes manifest read failure for install.
 
 Example:
 
@@ -289,9 +299,12 @@ Theme install behavior:
 
 - Relative theme paths are resolved from the plugin root.
 - Theme name is the JSON basename.
+- `api.theme.install(...)` and `oc-themes` auto-sync share the same installer path.
+- Theme copy/write runs under cross-process lock key `tui-theme:<dest>`.
 - First install writes only when the destination file is missing.
 - If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
-- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed.
+- On `updated`, host skips rewrite when tracked `mtime`/`size` is unchanged.
+- When a theme already exists and state is not `updated`, host can still persist theme metadata when destination already exists.
 - Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
 - Global plugins persist installed themes under the global `themes` dir.
 - Invalid or unreadable theme files are ignored.
@@ -328,6 +341,7 @@ Slot notes:
 - `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
 - `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
 - `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
+- `api.plugins.add(spec)` can load theme-only packages (`oc-themes` with no `./tui`) as runtime entries.
 - `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
 - `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
 - `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
@@ -357,7 +371,11 @@ Metadata is persisted by plugin id.
 - External TUI plugins load from `tuiConfig.plugin`.
 - `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
 - External plugin resolution and import are parallel.
+- Packages with no `./tui` entrypoint and valid `oc-themes` are loaded as synthetic no-op TUI plugin modules.
+- Theme-only packages loaded this way appear in `api.plugins.list()` and plugin manager rows like other external plugins.
+- Packages with no `./tui` entrypoint and no valid `oc-themes` are skipped with warning.
 - External plugin activation is sequential to keep command, route, and side-effect order deterministic.
+- Theme auto-sync from `oc-themes` runs before plugin `tui(...)` execution and only on metadata state `first` or `updated`.
 - File plugins that fail initially are retried once after waiting for config dependency installation.
 - Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
 - Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
@@ -400,6 +418,7 @@ The plugin manager is exposed as a command with title `Plugins` and value `plugi
 - Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
 - Manager install uses `api.plugins.install(spec, { global })`.
 - If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
+- `tui` target detection includes `exports["./tui"]` and valid `oc-themes`.
 - If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
 - If runtime add fails, TUI shows a warning and restart remains the fallback.
 

+ 3 - 1
packages/opencode/src/cli/cmd/plug.ts

@@ -115,7 +115,9 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
       if (manifest.code === "manifest_no_targets") {
         inspect.stop("No plugin targets found", 1)
         dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
-        dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.')
+        dep.log.info(
+          'Expected one of: exports["./tui"], exports["./server"], package.json main for server, or package.json["oc-themes"] for tui themes.',
+        )
         return false
       }
 

+ 209 - 178
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

@@ -18,7 +18,14 @@ import { Log } from "@/util/log"
 import { errorData, errorMessage } from "@/util/error"
 import { isRecord } from "@/util/record"
 import { Instance } from "@/project/instance"
-import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared"
+import {
+  readPackageThemes,
+  readPluginId,
+  readV1Plugin,
+  resolvePluginId,
+  type PluginPackage,
+  type PluginSource,
+} from "@/plugin/shared"
 import { PluginLoader } from "@/plugin/loader"
 import { PluginMeta } from "@/plugin/meta"
 import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
@@ -26,6 +33,7 @@ import { hasTheme, upsertTheme } from "../context/theme"
 import { Global } from "@/global"
 import { Filesystem } from "@/util/filesystem"
 import { Process } from "@/util/process"
+import { Flock } from "@/util/flock"
 import { Flag } from "@/flag/flag"
 import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
 import { setupSlots, Slot as View } from "./slots"
@@ -39,8 +47,9 @@ type PluginLoad = {
   source: PluginSource | "internal"
   id: string
   module: TuiPluginModule
-  theme_meta: TuiConfig.PluginMeta
+  origin: Config.PluginOrigin
   theme_root: string
+  theme_files: string[]
 }
 
 type Api = HostPluginApi
@@ -67,12 +76,15 @@ type RuntimeState = {
   slots: HostSlots
   plugins: PluginEntry[]
   plugins_by_id: Map<string, PluginEntry>
-  pending: Map<string, TuiConfig.PluginRecord>
+  pending: Map<string, Config.PluginOrigin>
 }
 
 const log = Log.create({ service: "tui.plugin" })
 const DISPOSE_TIMEOUT_MS = 5000
 const KV_KEY = "plugin_enabled"
+const EMPTY_TUI: TuiPluginModule = {
+  tui: async () => {},
+}
 
 function fail(message: string, data: Record<string, unknown>) {
   if (!("error" in data)) {
@@ -134,7 +146,7 @@ function resolveRoot(root: string) {
 }
 
 function createThemeInstaller(
-  meta: TuiConfig.PluginMeta,
+  meta: Config.PluginOrigin,
   root: string,
   spec: string,
   plugin: PluginEntry,
@@ -153,159 +165,70 @@ function createThemeInstaller(
     const stat = await Filesystem.statAsync(src)
     const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
     const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
-    const exists = hasTheme(name)
-    const prev = plugin.themes[name]
-
-    if (exists) {
-      if (plugin.meta.state !== "updated") return
-      if (!prev) {
-        if (await Filesystem.exists(dest)) {
-          plugin.themes[name] = {
-            src,
-            dest,
-            mtime,
-            size,
-          }
-          await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
-            log.warn("failed to track tui plugin theme", {
-              path: spec,
-              id: plugin.id,
-              theme: src,
-              dest,
-              error,
-            })
-          })
-        }
-        return
-      }
-      if (prev.dest !== dest) return
-      if (prev.mtime === mtime && prev.size === size) return
-    }
-
-    const text = await Filesystem.readText(src).catch((error) => {
-      log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
-      return
-    })
-    if (text === undefined) return
-
-    const fail = Symbol()
-    const data = await Promise.resolve(text)
-      .then((x) => JSON.parse(x))
-      .catch((error) => {
-        log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
-        return fail
-      })
-    if (data === fail) return
-
-    if (!isTheme(data)) {
-      log.warn("invalid tui plugin theme", { path: spec, theme: src })
-      return
-    }
-
-    if (exists || !(await Filesystem.exists(dest))) {
-      await Filesystem.write(dest, text).catch((error) => {
-        log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
-      })
-    }
-
-    upsertTheme(name, data)
-    plugin.themes[name] = {
+    const info = {
       src,
       dest,
       mtime,
       size,
     }
-    await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
-      log.warn("failed to track tui plugin theme", {
-        path: spec,
-        id: plugin.id,
-        theme: src,
-        dest,
-        error,
-      })
-    })
-  }
-}
 
-async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
-  const plan = PluginLoader.plan(cfg.item)
-  if (plan.deprecated) return
+    await Flock.withLock(`tui-theme:${dest}`, async () => {
+      const save = async () => {
+        plugin.themes[name] = info
+        await PluginMeta.setTheme(plugin.id, name, info).catch((error) => {
+          log.warn("failed to track tui plugin theme", {
+            path: spec,
+            id: plugin.id,
+            theme: src,
+            dest,
+            error,
+          })
+        })
+      }
 
-  log.info("loading tui plugin", { path: plan.spec, retry })
-  const resolved = await PluginLoader.resolve(plan, "tui")
-  if (!resolved.ok) {
-    if (resolved.stage === "missing") {
-      warn("tui plugin has no entrypoint", {
-        path: plan.spec,
-        retry,
-        message: resolved.message,
+      const exists = hasTheme(name)
+      const prev = plugin.themes[name]
+      if (exists) {
+        if (plugin.meta.state !== "updated") {
+          if (!prev && (await Filesystem.exists(dest))) {
+            await save()
+          }
+          return
+        }
+        if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return
+      }
+
+      const text = await Filesystem.readText(src).catch((error) => {
+        log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
+        return
       })
-      return
-    }
+      if (text === undefined) return
+
+      const fail = Symbol()
+      const data = await Promise.resolve(text)
+        .then((x) => JSON.parse(x))
+        .catch((error) => {
+          log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
+          return fail
+        })
+      if (data === fail) return
 
-    if (resolved.stage === "install") {
-      fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
-      return
-    }
-    if (resolved.stage === "compatibility") {
-      fail("tui plugin incompatible", { path: plan.spec, retry, error: resolved.error })
-      return
-    }
-    fail("failed to resolve tui plugin entry", { path: plan.spec, retry, error: resolved.error })
-    return
-  }
+      if (!isTheme(data)) {
+        log.warn("invalid tui plugin theme", { path: spec, theme: src })
+        return
+      }
 
-  const loaded = await PluginLoader.load(resolved.value)
-  if (!loaded.ok) {
-    fail("failed to load tui plugin", {
-      path: plan.spec,
-      target: resolved.value.entry,
-      retry,
-      error: loaded.error,
-    })
-    return
-  }
+      if (exists || !(await Filesystem.exists(dest))) {
+        await Filesystem.write(dest, text).catch((error) => {
+          log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
+        })
+      }
 
-  const mod = await Promise.resolve()
-    .then(() => {
-      return readV1Plugin(loaded.value.mod as Record<string, unknown>, plan.spec, "tui") as TuiPluginModule
-    })
-    .catch((error) => {
-      fail("failed to load tui plugin", {
-        path: plan.spec,
-        target: loaded.value.entry,
-        retry,
-        error,
-      })
-      return
+      upsertTheme(name, data)
+      await save()
+    }).catch((error) => {
+      log.warn("failed to lock tui plugin theme install", { path: spec, theme: src, dest, error })
     })
-  if (!mod) return
-
-  const id = await resolvePluginId(
-    loaded.value.source,
-    plan.spec,
-    loaded.value.target,
-    readPluginId(mod.id, plan.spec),
-    loaded.value.pkg,
-  ).catch((error) => {
-    fail("failed to load tui plugin", { path: plan.spec, target: loaded.value.target, retry, error })
-    return
-  })
-  if (!id) return
-
-  return {
-    options: plan.options,
-    spec: plan.spec,
-    target: loaded.value.target,
-    retry,
-    source: loaded.value.source,
-    id,
-    module: mod,
-    theme_meta: {
-      scope: cfg.scope,
-      source: cfg.source,
-    },
-    theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target),
   }
 }
 
@@ -350,11 +273,38 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
     source: "internal",
     id: item.id,
     module: item,
-    theme_meta: {
+    origin: {
+      spec,
       scope: "global",
       source: target,
     },
     theme_root: process.cwd(),
+    theme_files: [],
+  }
+}
+
+async function readThemeFiles(spec: string, pkg?: PluginPackage) {
+  if (!pkg) return [] as string[]
+  return Promise.resolve()
+    .then(() => readPackageThemes(spec, pkg))
+    .catch((error) => {
+      warn("invalid tui plugin oc-themes", {
+        path: spec,
+        pkg: pkg.pkg,
+        error,
+      })
+      return [] as string[]
+    })
+}
+
+async function syncPluginThemes(plugin: PluginEntry) {
+  if (!plugin.load.theme_files.length) return
+  if (plugin.meta.state === "same") return
+  const install = createThemeInstaller(plugin.load.origin, plugin.load.theme_root, plugin.load.spec, plugin)
+  for (const file of plugin.load.theme_files) {
+    await install(file).catch((error) => {
+      warn("failed to sync tui plugin oc-themes", { path: plugin.load.spec, id: plugin.id, theme: file, error })
+    })
   }
 }
 
@@ -489,6 +439,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
   const api = pluginApi(state, plugin, scope, plugin.id)
   const ok = await Promise.resolve()
     .then(async () => {
+      await syncPluginThemes(plugin)
       await plugin.plugin(api, plugin.load.options, plugin.meta)
       return true
     })
@@ -555,7 +506,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
   }
 
   const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
-    install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
+    install: createThemeInstaller(load.origin, load.theme_root, load.spec, plugin),
   })
 
   const event: TuiPluginApi["event"] = {
@@ -637,28 +588,108 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
   }
 }
 
-async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise<void>) {
-  const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item)))
-  const ready: PluginLoad[] = []
-  let deps: Promise<void> | undefined
-
-  for (let i = 0; i < list.length; i++) {
-    let entry = loaded[i]
-    if (!entry) {
-      const item = list[i]
-      if (!item) continue
-      if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
-      deps ??= wait().catch((error) => {
+async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
+  return PluginLoader.loadExternal({
+    items: list,
+    kind: "tui",
+    wait: async () => {
+      await wait().catch((error) => {
         log.warn("failed waiting for tui plugin dependencies", { error })
       })
-      await deps
-      entry = await loadExternalPlugin(item, true)
-    }
-    if (!entry) continue
-    ready.push(entry)
-  }
+    },
+    finish: async (loaded, origin, retry) => {
+      const mod = await Promise.resolve()
+        .then(() => readV1Plugin(loaded.mod as Record<string, unknown>, loaded.spec, "tui") as TuiPluginModule)
+        .catch((error) => {
+          fail("failed to load tui plugin", {
+            path: loaded.spec,
+            target: loaded.entry,
+            retry,
+            error,
+          })
+          return
+        })
+      if (!mod) return
+
+      const id = await resolvePluginId(
+        loaded.source,
+        loaded.spec,
+        loaded.target,
+        readPluginId(mod.id, loaded.spec),
+        loaded.pkg,
+      ).catch((error) => {
+        fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
+        return
+      })
+      if (!id) return
 
-  return ready
+      const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
+
+      return {
+        options: loaded.options,
+        spec: loaded.spec,
+        target: loaded.target,
+        retry,
+        source: loaded.source,
+        id,
+        module: mod,
+        origin,
+        theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
+        theme_files,
+      }
+    },
+    missing: async (loaded, origin, retry) => {
+      const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
+      if (!theme_files.length) return
+
+      const name =
+        typeof loaded.pkg?.json.name === "string" && loaded.pkg.json.name.trim().length > 0
+          ? loaded.pkg.json.name.trim()
+          : undefined
+      const id = await resolvePluginId(loaded.source, loaded.spec, loaded.target, name, loaded.pkg).catch((error) => {
+        fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
+        return
+      })
+      if (!id) return
+
+      return {
+        options: loaded.options,
+        spec: loaded.spec,
+        target: loaded.target,
+        retry,
+        source: loaded.source,
+        id,
+        module: EMPTY_TUI,
+        origin,
+        theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
+        theme_files,
+      }
+    },
+    report: {
+      start(candidate, retry) {
+        log.info("loading tui plugin", { path: candidate.plan.spec, retry })
+      },
+      missing(candidate, retry, message) {
+        warn("tui plugin has no entrypoint", { path: candidate.plan.spec, retry, message })
+      },
+      error(candidate, retry, stage, error, resolved) {
+        const spec = candidate.plan.spec
+        if (stage === "install") {
+          fail("failed to resolve tui plugin", { path: spec, retry, error })
+          return
+        }
+        if (stage === "compatibility") {
+          fail("tui plugin incompatible", { path: spec, retry, error })
+          return
+        }
+        if (stage === "entry") {
+          fail("failed to resolve tui plugin entry", { path: spec, retry, error })
+          return
+        }
+        fail("failed to load tui plugin", { path: spec, target: resolved?.entry, retry, error })
+      },
+    },
+  })
 }
 
 async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
@@ -692,12 +723,12 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
       })
     }
 
-    const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
+    const info = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
     const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
     const plugin: PluginEntry = {
       id: entry.id,
       load: entry,
-      meta: row,
+      meta: info,
       themes,
       plugin: entry.module.tui,
       enabled: true,
@@ -712,9 +743,9 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
   return { plugins, ok }
 }
 
-function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
+function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
   return {
-    item: spec,
+    spec,
     scope: "local",
     source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
   }
@@ -752,8 +783,8 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
   const spec = raw.trim()
   if (!spec) return false
 
-  const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
-  const next = Config.pluginSpecifier(cfg.item)
+  const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
+  const next = Config.pluginSpecifier(cfg.spec)
   if (state.plugins.some((plugin) => plugin.load.spec === next)) {
     state.pending.delete(spec)
     return true
@@ -837,7 +868,7 @@ async function installPluginBySpec(
     if (manifest.code === "manifest_no_targets") {
       return {
         ok: false,
-        message: `"${spec}" does not expose plugin entrypoints in package.json`,
+        message: `"${spec}" does not expose plugin entrypoints or oc-themes in package.json`,
       }
     }
 
@@ -872,9 +903,9 @@ async function installPluginBySpec(
   const tui = manifest.targets.find((item) => item.kind === "tui")
   if (tui) {
     const file = patch.items.find((item) => item.kind === "tui")?.file
-    const item = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
+    const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
     state.pending.set(spec, {
-      item,
+      spec: next,
       scope: global ? "global" : "local",
       source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
     })
@@ -959,9 +990,9 @@ export namespace TuiPluginRuntime {
       directory: cwd,
       fn: async () => {
         const config = await TuiConfig.get()
-        const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? [])
-        if (Flag.OPENCODE_PURE && config.plugin_records?.length) {
-          log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length })
+        const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
+        if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
+          log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
         }
 
         for (const item of INTERNAL_TUI_PLUGINS) {

+ 85 - 61
packages/opencode/src/config/config.ts

@@ -47,6 +47,12 @@ export namespace Config {
 
   export type PluginOptions = z.infer<typeof PluginOptions>
   export type PluginSpec = z.infer<typeof PluginSpec>
+  export type PluginScope = "global" | "local"
+  export type PluginOrigin = {
+    spec: PluginSpec
+    source: string
+    scope: PluginScope
+  }
 
   const log = Log.create({ service: "config" })
 
@@ -72,9 +78,6 @@ export namespace Config {
   // Custom merge function that concatenates array fields instead of replacing them
   function mergeConfigConcatArrays(target: Info, source: Info): Info {
     const merged = mergeDeep(target, source)
-    if (target.plugin && source.plugin) {
-      merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
-    }
     if (target.instructions && source.instructions) {
       merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
     }
@@ -297,31 +300,19 @@ export namespace Config {
     return resolved
   }
 
-  /**
-   * Deduplicates plugins by name, with later entries (higher priority) winning.
-   * Priority order (highest to lowest):
-   * 1. Local plugin/ directory
-   * 2. Local opencode.json
-   * 3. Global plugin/ directory
-   * 4. Global opencode.json
-   *
-   * Since plugins are added in low-to-high priority order,
-   * we reverse, deduplicate (keeping first occurrence), then restore order.
-   */
-  export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
-    const seenNames = new Set<string>()
-    const uniqueSpecifiers: PluginSpec[] = []
-
-    for (const specifier of plugins.toReversed()) {
-      const spec = pluginSpecifier(specifier)
+  export function deduplicatePluginOrigins(plugins: PluginOrigin[]): PluginOrigin[] {
+    const seen = new Set<string>()
+    const list: PluginOrigin[] = []
+
+    for (const plugin of plugins.toReversed()) {
+      const spec = pluginSpecifier(plugin.spec)
       const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
-      if (!seenNames.has(name)) {
-        seenNames.add(name)
-        uniqueSpecifiers.push(specifier)
-      }
+      if (seen.has(name)) continue
+      seen.add(name)
+      list.push(plugin)
     }
 
-    return uniqueSpecifiers.toReversed()
+    return list.toReversed()
   }
 
   export const McpLocal = z
@@ -997,7 +988,9 @@ export namespace Config {
       ref: "Config",
     })
 
-  export type Info = z.output<typeof Info>
+  export type Info = z.output<typeof Info> & {
+    plugin_origins?: PluginOrigin[]
+  }
 
   type State = {
     config: Info
@@ -1044,6 +1037,11 @@ export namespace Config {
     }, input)
   }
 
+  function writable(info: Info) {
+    const { plugin_origins, ...next } = info
+    return next
+  }
+
   function parseConfig(text: string, filepath: string): Info {
     const errors: JsoncParseError[] = []
     const data = parseJsonc(text, errors, { allowTrailingComma: true })
@@ -1208,6 +1206,30 @@ export namespace Config {
           const auth = yield* authSvc.all().pipe(Effect.orDie)
 
           let result: Info = {}
+
+          const scope = (source: string): PluginScope => {
+            if (source.startsWith("http://") || source.startsWith("https://")) return "global"
+            if (source === "OPENCODE_CONFIG_CONTENT") return "local"
+            if (Instance.containsPath(source)) return "local"
+            return "global"
+          }
+
+          const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => {
+            if (!list?.length) return
+            const hit = kind ?? scope(source)
+            const plugins = deduplicatePluginOrigins([
+              ...(result.plugin_origins ?? []),
+              ...list.map((spec) => ({ spec, source, scope: hit })),
+            ])
+            result.plugin = plugins.map((item) => item.spec)
+            result.plugin_origins = plugins
+          }
+
+          const merge = (source: string, next: Info, kind?: PluginScope) => {
+            result = mergeConfigConcatArrays(result, next)
+            track(source, next.plugin, kind)
+          }
+
           for (const [key, value] of Object.entries(auth)) {
             if (value.type === "wellknown") {
               const url = key.replace(/\/+$/, "")
@@ -1220,21 +1242,21 @@ export namespace Config {
               const wellknown = (yield* Effect.promise(() => response.json())) as any
               const remoteConfig = wellknown.config ?? {}
               if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
-              result = mergeConfigConcatArrays(
-                result,
-                yield* loadConfig(JSON.stringify(remoteConfig), {
-                  dir: path.dirname(`${url}/.well-known/opencode`),
-                  source: `${url}/.well-known/opencode`,
-                }),
-              )
+              const source = `${url}/.well-known/opencode`
+              const next = yield* loadConfig(JSON.stringify(remoteConfig), {
+                dir: path.dirname(source),
+                source,
+              })
+              merge(source, next, "global")
               log.debug("loaded remote config from well-known", { url })
             }
           }
 
-          result = mergeConfigConcatArrays(result, yield* getGlobal())
+          const global = yield* getGlobal()
+          merge(Global.Path.config, global, "global")
 
           if (Flag.OPENCODE_CONFIG) {
-            result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
+            merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
             log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
           }
 
@@ -1242,7 +1264,7 @@ export namespace Config {
             for (const file of yield* Effect.promise(() =>
               ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
             )) {
-              result = mergeConfigConcatArrays(result, yield* loadFile(file))
+              merge(file, yield* loadFile(file), "local")
             }
           }
 
@@ -1260,9 +1282,10 @@ export namespace Config {
 
           for (const dir of unique(directories)) {
             if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
-              for (const file of ["opencode.jsonc", "opencode.json"]) {
-                log.debug(`loading config from ${path.join(dir, file)}`)
-                result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
+              for (const file of ["opencode.json", "opencode.jsonc"]) {
+                const source = path.join(dir, file)
+                log.debug(`loading config from ${source}`)
+                merge(source, yield* loadFile(source))
                 result.agent ??= {}
                 result.mode ??= {}
                 result.plugin ??= []
@@ -1280,17 +1303,17 @@ export namespace Config {
             result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
             result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
             result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
-            result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
+            const list = yield* Effect.promise(() => loadPlugin(dir))
+            track(dir, list)
           }
 
           if (process.env.OPENCODE_CONFIG_CONTENT) {
-            result = mergeConfigConcatArrays(
-              result,
-              yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
-                dir: ctx.directory,
-                source: "OPENCODE_CONFIG_CONTENT",
-              }),
-            )
+            const source = "OPENCODE_CONFIG_CONTENT"
+            const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
+              dir: ctx.directory,
+              source,
+            })
+            merge(source, next, "local")
             log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
           }
 
@@ -1309,13 +1332,12 @@ export namespace Config {
 
               const config = Option.getOrUndefined(configOpt)
               if (config) {
-                result = mergeConfigConcatArrays(
-                  result,
-                  yield* loadConfig(JSON.stringify(config), {
-                    dir: path.dirname(`${active.url}/api/config`),
-                    source: `${active.url}/api/config`,
-                  }),
-                )
+                const source = `${active.url}/api/config`
+                const next = yield* loadConfig(JSON.stringify(config), {
+                  dir: path.dirname(source),
+                  source,
+                })
+                merge(source, next, "global")
               }
             }).pipe(
               Effect.catch((err) => {
@@ -1328,8 +1350,9 @@ export namespace Config {
           }
 
           if (existsSync(managedDir)) {
-            for (const file of ["opencode.jsonc", "opencode.json"]) {
-              result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
+            for (const file of ["opencode.json", "opencode.jsonc"]) {
+              const source = path.join(managedDir, file)
+              merge(source, yield* loadFile(source), "global")
             }
           }
 
@@ -1372,8 +1395,6 @@ export namespace Config {
             result.compaction = { ...result.compaction, prune: false }
           }
 
-          result.plugin = deduplicatePlugins(result.plugin ?? [])
-
           return {
             config: result,
             directories,
@@ -1403,7 +1424,9 @@ export namespace Config {
           const dir = yield* InstanceState.directory
           const file = path.join(dir, "config.json")
           const existing = yield* loadFile(file)
-          yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
+          yield* fs
+            .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
+            .pipe(Effect.orDie)
           yield* Effect.promise(() => Instance.dispose())
         })
 
@@ -1427,15 +1450,16 @@ export namespace Config {
         const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
           const file = globalConfigFile()
           const before = (yield* readConfigFile(file)) ?? "{}"
+          const input = writable(config)
 
           let next: Info
           if (!file.endsWith(".jsonc")) {
             const existing = parseConfig(before, file)
-            const merged = mergeDeep(existing, config)
+            const merged = mergeDeep(writable(existing), input)
             yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
             next = merged
           } else {
-            const updated = patchJsonc(before, config)
+            const updated = patchJsonc(before, input)
             next = parseConfig(updated, file)
             yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
           }

+ 2 - 9
packages/opencode/src/config/paths.ts

@@ -9,14 +9,7 @@ import { Global } from "@/global"
 
 export namespace ConfigPaths {
   export async function projectFiles(name: string, directory: string, worktree: string) {
-    const files: string[] = []
-    for (const file of [`${name}.jsonc`, `${name}.json`]) {
-      const found = await Filesystem.findUp(file, directory, worktree)
-      for (const resolved of found.toReversed()) {
-        files.push(resolved)
-      }
-    }
-    return files
+    return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
   }
 
   export async function directories(directory: string, worktree: string) {
@@ -43,7 +36,7 @@ export namespace ConfigPaths {
   }
 
   export function fileInDirectory(dir: string, name: string) {
-    return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
+    return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
   }
 
   export const JsonError = NamedError.create(

+ 0 - 0
packages/opencode/src/config/migrate-tui-config.ts → packages/opencode/src/config/tui-migrate.ts


+ 10 - 61
packages/opencode/src/config/tui.ts

@@ -3,72 +3,33 @@ import z from "zod"
 import { mergeDeep, unique } from "remeda"
 import { Config } from "./config"
 import { ConfigPaths } from "./paths"
-import { migrateTuiConfig } from "./migrate-tui-config"
+import { migrateTuiConfig } from "./tui-migrate"
 import { TuiInfo } from "./tui-schema"
 import { Instance } from "@/project/instance"
 import { Flag } from "@/flag/flag"
 import { Log } from "@/util/log"
 import { isRecord } from "@/util/record"
 import { Global } from "@/global"
-import { parsePluginSpecifier } from "@/plugin/shared"
 
 export namespace TuiConfig {
   const log = Log.create({ service: "tui.config" })
 
   export const Info = TuiInfo
 
-  export type PluginMeta = {
-    scope: "global" | "local"
-    source: string
-  }
-
-  export type PluginRecord = {
-    item: Config.PluginSpec
-    scope: PluginMeta["scope"]
-    source: string
-  }
-
-  type PluginEntry = {
-    item: Config.PluginSpec
-    meta: PluginMeta
-  }
-
   type Acc = {
     result: Info
-    entries: PluginEntry[]
   }
 
   export type Info = z.output<typeof Info> & {
     // Internal resolved plugin list used by runtime loading.
-    plugin_records?: PluginRecord[]
+    plugin_origins?: Config.PluginOrigin[]
   }
 
-  function pluginScope(file: string): PluginMeta["scope"] {
+  function pluginScope(file: string): Config.PluginScope {
     if (Instance.containsPath(file)) return "local"
     return "global"
   }
 
-  function dedupePlugins(list: PluginEntry[]) {
-    const seen = new Set<string>()
-    const result: PluginEntry[] = []
-    for (const item of list.toReversed()) {
-      const spec = Config.pluginSpecifier(item.item)
-      const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
-      if (seen.has(name)) continue
-      seen.add(name)
-      result.push(item)
-    }
-    return result.toReversed()
-  }
-
-  function mergeInfo(target: Info, source: Info): Info {
-    const merged = mergeDeep(target, source)
-    if (target.plugin && source.plugin) {
-      merged.plugin = [...target.plugin, ...source.plugin]
-    }
-    return merged
-  }
-
   function customPath() {
     return Flag.OPENCODE_TUI_CONFIG
   }
@@ -95,19 +56,16 @@ export namespace TuiConfig {
 
   async function mergeFile(acc: Acc, file: string) {
     const data = await loadFile(file)
-    acc.result = mergeInfo(acc.result, data)
+    acc.result = mergeDeep(acc.result, data)
     if (!data.plugin?.length) return
 
     const scope = pluginScope(file)
-    for (const item of data.plugin) {
-      acc.entries.push({
-        item,
-        meta: {
-          scope,
-          source: file,
-        },
-      })
-    }
+    const plugins = Config.deduplicatePluginOrigins([
+      ...(acc.result.plugin_origins ?? []),
+      ...data.plugin.map((spec) => ({ spec, scope, source: file })),
+    ])
+    acc.result.plugin = plugins.map((item) => item.spec)
+    acc.result.plugin_origins = plugins
   }
 
   const state = Instance.state(async () => {
@@ -125,7 +83,6 @@ export namespace TuiConfig {
 
     const acc: Acc = {
       result: {},
-      entries: [],
     }
 
     for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
@@ -154,15 +111,7 @@ export namespace TuiConfig {
       }
     }
 
-    const merged = dedupePlugins(acc.entries)
     acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
-    const list = merged.map((item) => ({
-      item: item.item,
-      scope: item.meta.scope,
-      source: item.meta.source,
-    }))
-    acc.result.plugin = list.map((item) => item.item)
-    acc.result.plugin_records = list.length ? list : undefined
 
     const deps: Promise<void>[] = []
     if (acc.result.plugin?.length) {

+ 45 - 86
packages/opencode/src/plugin/index.ts

@@ -24,10 +24,6 @@ export namespace Plugin {
     hooks: Hooks[]
   }
 
-  type Loaded = {
-    row: PluginLoader.Loaded
-  }
-
   // Hook names that follow the (input, output) => Promise<void> trigger pattern
   type TriggerName = {
     [K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
@@ -78,22 +74,20 @@ export namespace Plugin {
     return result
   }
 
-  async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
-    const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect")
+  function publishPluginError(message: string) {
+    Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+  }
+
+  async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
+    const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
     if (plugin) {
-      await resolvePluginId(
-        load.row.source,
-        load.row.spec,
-        load.row.target,
-        readPluginId(plugin.id, load.row.spec),
-        load.row.pkg,
-      )
-      hooks.push(await (plugin as PluginModule).server(input, load.row.options))
+      await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
+      hooks.push(await (plugin as PluginModule).server(input, load.options))
       return
     }
 
-    for (const server of getLegacyPlugins(load.row.mod)) {
-      hooks.push(await server(input, load.row.options))
+    for (const server of getLegacyPlugins(load.mod)) {
+      hooks.push(await server(input, load.options))
     }
   }
 
@@ -142,87 +136,52 @@ export namespace Plugin {
             if (init._tag === "Some") hooks.push(init.value)
           }
 
-          const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
-          if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
-            log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
+          const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
+          if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
+            log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
           }
           if (plugins.length) yield* config.waitForDependencies()
 
           const loaded = yield* Effect.promise(() =>
-            Promise.all(
-              plugins.map(async (item) => {
-                const plan = PluginLoader.plan(item)
-                if (plan.deprecated) return
-                log.info("loading plugin", { path: plan.spec })
-
-                const resolved = await PluginLoader.resolve(plan, "server")
-                if (!resolved.ok) {
-                  if (resolved.stage === "missing") {
-                    log.warn("plugin has no server entrypoint", {
-                      path: plan.spec,
-                      message: resolved.message,
-                    })
+            PluginLoader.loadExternal({
+              items: plugins,
+              kind: "server",
+              report: {
+                start(candidate) {
+                  log.info("loading plugin", { path: candidate.plan.spec })
+                },
+                missing(candidate, _retry, message) {
+                  log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message })
+                },
+                error(candidate, _retry, stage, error, resolved) {
+                  const spec = candidate.plan.spec
+                  const cause = error instanceof Error ? (error.cause ?? error) : error
+                  const message = stage === "load" ? errorMessage(error) : errorMessage(cause)
+
+                  if (stage === "install") {
+                    const parsed = parsePluginSpecifier(spec)
+                    log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
+                    publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
                     return
                   }
 
-                  const cause =
-                    resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
-                  const message = errorMessage(cause)
-
-                  if (resolved.stage === "install") {
-                    const parsed = parsePluginSpecifier(plan.spec)
-                    log.error("failed to install plugin", {
-                      pkg: parsed.pkg,
-                      version: parsed.version,
-                      error: message,
-                    })
-                    Bus.publish(Session.Event.Error, {
-                      error: new NamedError.Unknown({
-                        message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`,
-                      }).toObject(),
-                    })
+                  if (stage === "compatibility") {
+                    log.warn("plugin incompatible", { path: spec, error: message })
+                    publishPluginError(`Plugin ${spec} skipped: ${message}`)
                     return
                   }
 
-                  if (resolved.stage === "compatibility") {
-                    log.warn("plugin incompatible", { path: plan.spec, error: message })
-                    Bus.publish(Session.Event.Error, {
-                      error: new NamedError.Unknown({
-                        message: `Plugin ${plan.spec} skipped: ${message}`,
-                      }).toObject(),
-                    })
+                  if (stage === "entry") {
+                    log.error("failed to resolve plugin server entry", { path: spec, error: message })
+                    publishPluginError(`Failed to load plugin ${spec}: ${message}`)
                     return
                   }
 
-                  log.error("failed to resolve plugin server entry", {
-                    path: plan.spec,
-                    error: message,
-                  })
-                  Bus.publish(Session.Event.Error, {
-                    error: new NamedError.Unknown({
-                      message: `Failed to load plugin ${plan.spec}: ${message}`,
-                    }).toObject(),
-                  })
-                  return
-                }
-
-                const mod = await PluginLoader.load(resolved.value)
-                if (!mod.ok) {
-                  const message = errorMessage(mod.error)
-                  log.error("failed to load plugin", { path: plan.spec, target: resolved.value.entry, error: message })
-                  Bus.publish(Session.Event.Error, {
-                    error: new NamedError.Unknown({
-                      message: `Failed to load plugin ${plan.spec}: ${message}`,
-                    }).toObject(),
-                  })
-                  return
-                }
-
-                return {
-                  row: mod.value,
-                }
-              }),
-            ),
+                  log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
+                  publishPluginError(`Failed to load plugin ${spec}: ${message}`)
+                },
+              },
+            }),
           )
           for (const load of loaded) {
             if (!load) continue
@@ -233,14 +192,14 @@ export namespace Plugin {
               try: () => applyPlugin(load, input, hooks),
               catch: (err) => {
                 const message = errorMessage(err)
-                log.error("failed to load plugin", { path: load.row.spec, error: message })
+                log.error("failed to load plugin", { path: load.spec, error: message })
                 return message
               },
             }).pipe(
               Effect.catch((message) =>
                 bus.publish(Session.Event.Error, {
                   error: new NamedError.Unknown({
-                    message: `Failed to load plugin ${load.row.spec}: ${message}`,
+                    message: `Failed to load plugin ${load.spec}: ${message}`,
                   }).toObject(),
                 }),
               ),

+ 30 - 8
packages/opencode/src/plugin/install.ts

@@ -13,7 +13,7 @@ import { Filesystem } from "@/util/filesystem"
 import { Flock } from "@/util/flock"
 import { isRecord } from "@/util/record"
 
-import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
+import { parsePluginSpecifier, readPackageThemes, readPluginPackage, resolvePluginTarget } from "./shared"
 
 type Mode = "noop" | "add" | "replace"
 type Kind = "server" | "tui"
@@ -142,19 +142,26 @@ function hasMainTarget(pkg: Record<string, unknown>) {
   return Boolean(main.trim())
 }
 
-function packageTargets(pkg: Record<string, unknown>) {
+function packageTargets(pkg: { json: Record<string, unknown>; dir: string; pkg: string }) {
+  const spec =
+    typeof pkg.json.name === "string" && pkg.json.name.trim().length > 0 ? pkg.json.name.trim() : path.basename(pkg.dir)
   const targets: Target[] = []
-  const server = exportTarget(pkg, "server")
+  const server = exportTarget(pkg.json, "server")
   if (server) {
     targets.push({ kind: "server", opts: server.opts })
-  } else if (hasMainTarget(pkg)) {
+  } else if (hasMainTarget(pkg.json)) {
     targets.push({ kind: "server" })
   }
 
-  const tui = exportTarget(pkg, "tui")
+  const tui = exportTarget(pkg.json, "tui")
   if (tui) {
     targets.push({ kind: "tui", opts: tui.opts })
   }
+
+  if (!targets.some((item) => item.kind === "tui") && readPackageThemes(spec, pkg).length) {
+    targets.push({ kind: "tui" })
+  }
+
   return targets
 }
 
@@ -293,8 +300,23 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
     }
   }
 
-  const targets = packageTargets(pkg.item.json)
-  if (!targets.length) {
+  const targets = await Promise.resolve()
+    .then(() => packageTargets(pkg.item))
+    .then(
+      (item) => ({ ok: true as const, item }),
+      (error: unknown) => ({ ok: false as const, error }),
+    )
+
+  if (!targets.ok) {
+    return {
+      ok: false,
+      code: "manifest_read_failed",
+      file: pkg.item.pkg,
+      error: targets.error,
+    }
+  }
+
+  if (!targets.item.length) {
     return {
       ok: false,
       code: "manifest_no_targets",
@@ -304,7 +326,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
 
   return {
     ok: true,
-    targets,
+    targets: targets.item,
   }
 }
 

+ 99 - 62
packages/opencode/src/plugin/loader.ts

@@ -4,6 +4,7 @@ import {
   checkPluginCompatibility,
   createPluginEntry,
   isDeprecatedPlugin,
+  pluginSource,
   resolvePluginTarget,
   type PluginKind,
   type PluginPackage,
@@ -12,31 +13,42 @@ import {
 
 export namespace PluginLoader {
   export type Plan = {
-    item: Config.PluginSpec
     spec: string
     options: Config.PluginOptions | undefined
     deprecated: boolean
   }
-
   export type Resolved = Plan & {
     source: PluginSource
     target: string
     entry: string
     pkg?: PluginPackage
   }
-
+  export type Missing = Plan & {
+    source: PluginSource
+    target: string
+    pkg?: PluginPackage
+    message: string
+  }
   export type Loaded = Resolved & {
     mod: Record<string, unknown>
   }
 
-  export function plan(item: Config.PluginSpec): Plan {
+  type Candidate = { origin: Config.PluginOrigin; plan: Plan }
+  type Report = {
+    start?: (candidate: Candidate, retry: boolean) => void
+    missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
+    error?: (
+      candidate: Candidate,
+      retry: boolean,
+      stage: "install" | "entry" | "compatibility" | "load",
+      error: unknown,
+      resolved?: Resolved,
+    ) => void
+  }
+
+  function plan(item: Config.PluginSpec): Plan {
     const spec = Config.pluginSpecifier(item)
-    return {
-      item,
-      spec,
-      options: Config.pluginOptions(item),
-      deprecated: isDeprecatedPlugin(spec),
-    }
+    return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
   }
 
   export async function resolve(
@@ -44,68 +56,44 @@ export namespace PluginLoader {
     kind: PluginKind,
   ): Promise<
     | { ok: true; value: Resolved }
-    | { ok: false; stage: "missing"; message: string }
+    | { ok: false; stage: "missing"; value: Missing }
     | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
   > {
     let target = ""
     try {
       target = await resolvePluginTarget(plan.spec)
     } catch (error) {
-      return {
-        ok: false,
-        stage: "install",
-        error,
-      }
-    }
-    if (!target) {
-      return {
-        ok: false,
-        stage: "install",
-        error: new Error(`Plugin ${plan.spec} target is empty`),
-      }
+      return { ok: false, stage: "install", error }
     }
+    if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
 
     let base
     try {
       base = await createPluginEntry(plan.spec, target, kind)
     } catch (error) {
-      return {
-        ok: false,
-        stage: "entry",
-        error,
-      }
+      return { ok: false, stage: "entry", error }
     }
-
-    if (!base.entry) {
+    if (!base.entry)
       return {
         ok: false,
         stage: "missing",
-        message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
+        value: {
+          ...plan,
+          source: base.source,
+          target: base.target,
+          pkg: base.pkg,
+          message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
+        },
       }
-    }
 
     if (base.source === "npm") {
       try {
         await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
       } catch (error) {
-        return {
-          ok: false,
-          stage: "compatibility",
-          error,
-        }
+        return { ok: false, stage: "compatibility", error }
       }
     }
-
-    return {
-      ok: true,
-      value: {
-        ...plan,
-        source: base.source,
-        target: base.target,
-        entry: base.entry,
-        pkg: base.pkg,
-      },
-    }
+    return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
   }
 
   export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
@@ -113,25 +101,74 @@ export namespace PluginLoader {
     try {
       mod = await import(row.entry)
     } catch (error) {
-      return {
-        ok: false,
-        error,
-      }
+      return { ok: false, error }
     }
+    if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
+    return { ok: true, value: { ...row, mod } }
+  }
 
-    if (!mod) {
-      return {
-        ok: false,
-        error: new Error(`Plugin ${row.spec} module is empty`),
+  async function attempt<R>(
+    candidate: Candidate,
+    kind: PluginKind,
+    retry: boolean,
+    finish: ((load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
+    missing: ((value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
+    report: Report | undefined,
+  ): Promise<R | undefined> {
+    const plan = candidate.plan
+    if (plan.deprecated) return
+    report?.start?.(candidate, retry)
+    const resolved = await resolve(plan, kind)
+    if (!resolved.ok) {
+      if (resolved.stage === "missing") {
+        if (missing) {
+          const value = await missing(resolved.value, candidate.origin, retry)
+          if (value !== undefined) return value
+        }
+        report?.missing?.(candidate, retry, resolved.value.message, resolved.value)
+        return
       }
+      report?.error?.(candidate, retry, resolved.stage, resolved.error)
+      return
     }
+    const loaded = await load(resolved.value)
+    if (!loaded.ok) {
+      report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
+      return
+    }
+    if (!finish) return loaded.value as R
+    return finish(loaded.value, candidate.origin, retry)
+  }
 
-    return {
-      ok: true,
-      value: {
-        ...row,
-        mod,
-      },
+  type Input<R> = {
+    items: Config.PluginOrigin[]
+    kind: PluginKind
+    wait?: () => Promise<void>
+    finish?: (load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
+    missing?: (value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
+    report?: Report
+  }
+
+  export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
+    const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
+    const list: Array<Promise<R | undefined>> = []
+    for (const candidate of candidates) {
+      list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report))
+    }
+    const out = await Promise.all(list)
+    if (input.wait) {
+      let deps: Promise<void> | undefined
+      for (let i = 0; i < candidates.length; i++) {
+        if (out[i] !== undefined) continue
+        const candidate = candidates[i]
+        if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
+        deps ??= input.wait()
+        await deps
+        out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
+      }
     }
+    const ready: R[] = []
+    for (const item of out) if (item !== undefined) ready.push(item)
+    return ready
   }
 }

+ 40 - 6
packages/opencode/src/plugin/shared.ts

@@ -50,6 +50,10 @@ function resolveExportPath(raw: string, dir: string) {
   return path.resolve(dir, raw)
 }
 
+function isAbsolutePath(raw: string) {
+  return path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)
+}
+
 function extractExportValue(value: unknown): string | undefined {
   if (typeof value === "string") return value
   if (!isRecord(value)) return undefined
@@ -68,14 +72,18 @@ function packageMain(pkg: PluginPackage) {
   return next
 }
 
-function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
+function resolvePackageFile(spec: string, raw: string, kind: string, pkg: PluginPackage) {
   const resolved = resolveExportPath(raw, pkg.dir)
   const root = Filesystem.resolve(pkg.dir)
   const next = Filesystem.resolve(resolved)
   if (!Filesystem.contains(root, next)) {
     throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
   }
-  return pathToFileURL(next).href
+  return next
+}
+
+function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
+  return pathToFileURL(resolvePackageFile(spec, raw, kind, pkg)).href
 }
 
 function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
@@ -106,7 +114,7 @@ async function resolveDirectoryIndex(dir: string) {
 async function resolveTargetDirectory(target: string) {
   const file = targetPath(target)
   if (!file) return
-  const stat = Filesystem.stat(file)
+  const stat = await Filesystem.statAsync(file)
   if (!stat?.isDirectory()) return
   return file
 }
@@ -147,13 +155,13 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
 }
 
 export function isPathPluginSpec(spec: string) {
-  return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
+  return spec.startsWith("file://") || spec.startsWith(".") || isAbsolutePath(spec)
 }
 
 export async function resolvePathPluginTarget(spec: string) {
   const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
   const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
-  const stat = Filesystem.stat(file)
+  const stat = await Filesystem.statAsync(file)
   if (!stat?.isDirectory()) {
     if (spec.startsWith("file://")) return spec
     return pathToFileURL(file).href
@@ -190,7 +198,7 @@ export async function resolvePluginTarget(spec: string, parsed = parsePluginSpec
 
 export async function readPluginPackage(target: string): Promise<PluginPackage> {
   const file = target.startsWith("file://") ? fileURLToPath(target) : target
-  const stat = Filesystem.stat(file)
+  const stat = await Filesystem.statAsync(file)
   const dir = stat?.isDirectory() ? file : path.dirname(file)
   const pkg = path.join(dir, "package.json")
   const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
@@ -211,6 +219,32 @@ export async function createPluginEntry(spec: string, target: string, kind: Plug
   }
 }
 
+export function readPackageThemes(spec: string, pkg: PluginPackage) {
+  const field = pkg.json["oc-themes"]
+  if (field === undefined) return []
+  if (!Array.isArray(field)) {
+    throw new TypeError(`Plugin ${spec} has invalid oc-themes field`)
+  }
+
+  const list = field.map((item) => {
+    if (typeof item !== "string") {
+      throw new TypeError(`Plugin ${spec} has invalid oc-themes entry`)
+    }
+
+    const raw = item.trim()
+    if (!raw) {
+      throw new TypeError(`Plugin ${spec} has empty oc-themes entry`)
+    }
+    if (raw.startsWith("file://") || isAbsolutePath(raw)) {
+      throw new TypeError(`Plugin ${spec} oc-themes entry must be relative: ${item}`)
+    }
+
+    return resolvePackageFile(spec, raw, "oc-themes", pkg)
+  })
+
+  return Array.from(new Set(list))
+}
+
 export function readPluginId(id: unknown, spec: string) {
   if (id === undefined) return
   if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)

+ 29 - 4
packages/opencode/src/util/filesystem.ts

@@ -166,17 +166,42 @@ export namespace Filesystem {
     return !relative(parent, child).startsWith("..")
   }
 
-  export async function findUp(target: string, start: string, stop?: string) {
+  export async function findUp(
+    target: string,
+    start: string,
+    stop?: string,
+    options?: { rootFirst?: boolean },
+  ): Promise<string[]>
+  export async function findUp(
+    target: string[],
+    start: string,
+    stop?: string,
+    options?: { rootFirst?: boolean },
+  ): Promise<string[]>
+  export async function findUp(
+    target: string | string[],
+    start: string,
+    stop?: string,
+    options?: { rootFirst?: boolean },
+  ) {
+    const dirs = [start]
     let current = start
-    const result = []
     while (true) {
-      const search = join(current, target)
-      if (await exists(search)) result.push(search)
       if (stop === current) break
       const parent = dirname(current)
       if (parent === current) break
+      dirs.push(parent)
       current = parent
     }
+
+    const targets = Array.isArray(target) ? target : [target]
+    const result = []
+    for (const dir of options?.rootFirst ? dirs.toReversed() : dirs) {
+      for (const item of targets) {
+        const search = join(dir, item)
+        if (await exists(search)) result.push(search)
+      }
+    }
     return result
   }
 

+ 47 - 1
packages/opencode/test/cli/tui/plugin-add.test.ts

@@ -33,7 +33,7 @@ test("adds tui plugin at runtime from spec", async () => {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin: [],
-    plugin_records: undefined,
+    plugin_origins: undefined,
   })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -59,3 +59,49 @@ test("adds tui plugin at runtime from spec", async () => {
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
+
+test("retries runtime add for file plugins after dependency wait", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const mod = path.join(dir, "retry-plugin")
+      const spec = pathToFileURL(mod).href
+      const marker = path.join(dir, "retry-add.txt")
+      await fs.mkdir(mod, { recursive: true })
+      return { mod, spec, marker }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin: [],
+    plugin_origins: undefined,
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
+    await Bun.write(
+      path.join(tmp.extra.mod, "index.ts"),
+      `export default {
+  id: "demo.add.retry",
+  tui: async () => {
+    await Bun.write(${JSON.stringify(tmp.extra.marker)}, "called")
+  },
+}
+`,
+    )
+  })
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+
+    await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
+    expect(wait).toHaveBeenCalledTimes(1)
+    expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add.retry")?.active).toBe(true)
+  } finally {
+    await TuiPluginRuntime.dispose()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})

+ 1 - 1
packages/opencode/test/cli/tui/plugin-install.test.ts

@@ -52,7 +52,7 @@ test("installs plugin without loading it", async () => {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
   const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
     plugin: [],
-    plugin_records: undefined,
+    plugin_origins: undefined,
   }
   const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()

+ 16 - 16
packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts

@@ -46,9 +46,9 @@ test("loads npm tui plugin from package ./tui export", async () => {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
-    plugin_records: [
+    plugin_origins: [
       {
-        item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+        spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },
@@ -108,9 +108,9 @@ test("does not use npm package exports dot for tui entry", async () => {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin: [tmp.extra.spec],
-    plugin_records: [
+    plugin_origins: [
       {
-        item: tmp.extra.spec,
+        spec: tmp.extra.spec,
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },
@@ -171,9 +171,9 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin: [tmp.extra.spec],
-    plugin_records: [
+    plugin_origins: [
       {
-        item: tmp.extra.spec,
+        spec: tmp.extra.spec,
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },
@@ -234,9 +234,9 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin: [tmp.extra.spec],
-    plugin_records: [
+    plugin_origins: [
       {
-        item: tmp.extra.spec,
+        spec: tmp.extra.spec,
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },
@@ -293,9 +293,9 @@ test("does not use npm package main for tui entry", async () => {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin: [tmp.extra.spec],
-    plugin_records: [
+    plugin_origins: [
       {
-        item: tmp.extra.spec,
+        spec: tmp.extra.spec,
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },
@@ -359,9 +359,9 @@ test("does not use directory package main for tui entry", async () => {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin: [tmp.extra.spec],
-    plugin_records: [
+    plugin_origins: [
       {
-        item: tmp.extra.spec,
+        spec: tmp.extra.spec,
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },
@@ -407,9 +407,9 @@ test("uses directory index fallback for tui when package.json is missing", async
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin: [tmp.extra.spec],
-    plugin_records: [
+    plugin_origins: [
       {
-        item: tmp.extra.spec,
+        spec: tmp.extra.spec,
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },
@@ -465,9 +465,9 @@ test("uses npm package name when tui plugin id is omitted", async () => {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
-    plugin_records: [
+    plugin_origins: [
       {
-        item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+        spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },

+ 2 - 2
packages/opencode/test/cli/tui/plugin-loader-pure.test.ts

@@ -39,9 +39,9 @@ test("skips external tui plugins in pure mode", async () => {
 
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
-    plugin_records: [
+    plugin_origins: [
       {
-        item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+        spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },

+ 3 - 3
packages/opencode/test/cli/tui/plugin-loader.test.ts

@@ -468,14 +468,14 @@ test("continues loading when a plugin is missing config metadata", async () => {
       [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
       tmp.extra.bareSpec,
     ],
-    plugin_records: [
+    plugin_origins: [
       {
-        item: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
+        spec: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },
       {
-        item: tmp.extra.bareSpec,
+        spec: tmp.extra.bareSpec,
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },

+ 4 - 4
packages/opencode/test/cli/tui/plugin-toggle.test.ts

@@ -44,9 +44,9 @@ test("toggles plugin runtime state by exported id", async () => {
     plugin_enabled: {
       "demo.toggle": false,
     },
-    plugin_records: [
+    plugin_origins: [
       {
-        item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+        spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },
@@ -122,9 +122,9 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
     plugin_enabled: {
       "demo.startup": false,
     },
-    plugin_records: [
+    plugin_origins: [
       {
-        item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+        spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },

+ 71 - 12
packages/opencode/test/config/config.test.ts

@@ -1,4 +1,4 @@
-import { test, expect, describe, mock, afterEach, spyOn } from "bun:test"
+import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test"
 import { Effect, Layer, Option } from "effect"
 import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Config } from "../../src/config/config"
@@ -34,8 +34,13 @@ const emptyAuth = Layer.mock(Auth.Service)({
 // Get managed config directory from environment (set in preload.ts)
 const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
 
+beforeEach(async () => {
+  await Config.invalidate(true)
+})
+
 afterEach(async () => {
   await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
+  await Config.invalidate(true)
 })
 
 async function writeManagedSettings(settings: object, filename = "opencode.json") {
@@ -169,7 +174,7 @@ test("loads JSONC config file", async () => {
   })
 })
 
-test("merges multiple config files with correct precedence", async () => {
+test("jsonc overrides json in the same directory", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
       await writeConfig(
@@ -191,7 +196,7 @@ test("merges multiple config files with correct precedence", async () => {
     directory: tmp.path,
     fn: async () => {
       const config = await Config.get()
-      expect(config.model).toBe("override")
+      expect(config.model).toBe("base")
       expect(config.username).toBe("base")
     },
   })
@@ -1174,6 +1179,51 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
   })
 })
 
+test("keeps plugin origins aligned with merged plugin list", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const project = path.join(dir, "project")
+      const local = path.join(project, ".opencode")
+      await fs.mkdir(local, { recursive: true })
+
+      await Filesystem.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
+        }),
+      )
+
+      await Filesystem.write(
+        path.join(local, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
+        }),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: path.join(tmp.path, "project"),
+    fn: async () => {
+      const cfg = await Config.get()
+      const plugins = cfg.plugin ?? []
+      const origins = cfg.plugin_origins ?? []
+      const names = plugins.map((item) => Config.pluginSpecifier(item))
+
+      expect(names).toContain("shared-plugin@2.0.0")
+      expect(names).not.toContain("shared-plugin@1.0.0")
+      expect(names).toContain("global-only@1.0.0")
+      expect(names).toContain("local-only@1.0.0")
+
+      expect(origins.map((item) => item.spec)).toEqual(plugins)
+      const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
+      expect(hit?.scope).toBe("local")
+    },
+  })
+})
+
 // Legacy tools migration tests
 
 test("migrates legacy tools config to permissions - allow", async () => {
@@ -1550,7 +1600,7 @@ test("project config can override MCP server enabled status", async () => {
     init: async (dir) => {
       // Simulates a base config (like from remote .well-known) with disabled MCP
       await Filesystem.write(
-        path.join(dir, "opencode.jsonc"),
+        path.join(dir, "opencode.json"),
         JSON.stringify({
           $schema: "https://opencode.ai/config.json",
           mcp: {
@@ -1569,7 +1619,7 @@ test("project config can override MCP server enabled status", async () => {
       )
       // Project config enables just jira
       await Filesystem.write(
-        path.join(dir, "opencode.json"),
+        path.join(dir, "opencode.jsonc"),
         JSON.stringify({
           $schema: "https://opencode.ai/config.json",
           mcp: {
@@ -1608,7 +1658,7 @@ test("MCP config deep merges preserving base config properties", async () => {
     init: async (dir) => {
       // Base config with full MCP definition
       await Filesystem.write(
-        path.join(dir, "opencode.jsonc"),
+        path.join(dir, "opencode.json"),
         JSON.stringify({
           $schema: "https://opencode.ai/config.json",
           mcp: {
@@ -1625,7 +1675,7 @@ test("MCP config deep merges preserving base config properties", async () => {
       )
       // Override just enables it, should preserve other properties
       await Filesystem.write(
-        path.join(dir, "opencode.json"),
+        path.join(dir, "opencode.jsonc"),
         JSON.stringify({
           $schema: "https://opencode.ai/config.json",
           mcp: {
@@ -1875,11 +1925,20 @@ describe("resolvePluginSpec", () => {
   })
 })
 
-describe("deduplicatePlugins", () => {
+describe("deduplicatePluginOrigins", () => {
+  const dedupe = (plugins: Config.PluginSpec[]) =>
+    Config.deduplicatePluginOrigins(
+      plugins.map((spec) => ({
+        spec,
+        source: "",
+        scope: "global" as const,
+      })),
+    ).map((item) => item.spec)
+
   test("removes duplicates keeping higher priority (later entries)", () => {
     const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"]
 
-    const result = Config.deduplicatePlugins(plugins)
+    const result = dedupe(plugins)
 
     expect(result).toContain("global-plugin@1.0.0")
     expect(result).toContain("local-plugin@2.0.0")
@@ -1891,7 +1950,7 @@ describe("deduplicatePlugins", () => {
   test("keeps path plugins separate from package plugins", () => {
     const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
 
-    const result = Config.deduplicatePlugins(plugins)
+    const result = dedupe(plugins)
 
     expect(result).toEqual(plugins)
   })
@@ -1899,7 +1958,7 @@ describe("deduplicatePlugins", () => {
   test("deduplicates direct path plugins by exact spec", () => {
     const plugins = ["file:///project/.opencode/plugin/demo.ts", "file:///project/.opencode/plugin/demo.ts"]
 
-    const result = Config.deduplicatePlugins(plugins)
+    const result = dedupe(plugins)
 
     expect(result).toEqual(["file:///project/.opencode/plugin/demo.ts"])
   })
@@ -1907,7 +1966,7 @@ describe("deduplicatePlugins", () => {
   test("preserves order of remaining plugins", () => {
     const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]
 
-    const result = Config.deduplicatePlugins(plugins)
+    const result = dedupe(plugins)
 
     expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
   })

+ 90 - 11
packages/opencode/test/config/tui.test.ts

@@ -1,20 +1,99 @@
-import { afterEach, expect, test } from "bun:test"
+import { afterEach, beforeEach, expect, test } from "bun:test"
 import path from "path"
 import fs from "fs/promises"
 import { tmpdir } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
+import { Config } from "../../src/config/config"
 import { TuiConfig } from "../../src/config/tui"
 import { Global } from "../../src/global"
 import { Filesystem } from "../../src/util/filesystem"
 
 const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
 
+beforeEach(async () => {
+  await Config.invalidate(true)
+})
+
 afterEach(async () => {
   delete process.env.OPENCODE_CONFIG
   delete process.env.OPENCODE_TUI_CONFIG
+  await fs.rm(path.join(Global.Path.config, "opencode.json"), { force: true }).catch(() => {})
+  await fs.rm(path.join(Global.Path.config, "opencode.jsonc"), { force: true }).catch(() => {})
   await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
   await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
   await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
+  await Config.invalidate(true)
+})
+
+test("keeps server and tui plugin merge semantics aligned", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const local = path.join(dir, ".opencode")
+      await fs.mkdir(local, { recursive: true })
+
+      await Bun.write(
+        path.join(Global.Path.config, "opencode.json"),
+        JSON.stringify(
+          {
+            plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
+          },
+          null,
+          2,
+        ),
+      )
+      await Bun.write(
+        path.join(Global.Path.config, "tui.json"),
+        JSON.stringify(
+          {
+            plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
+          },
+          null,
+          2,
+        ),
+      )
+
+      await Bun.write(
+        path.join(local, "opencode.json"),
+        JSON.stringify(
+          {
+            plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
+          },
+          null,
+          2,
+        ),
+      )
+      await Bun.write(
+        path.join(local, "tui.json"),
+        JSON.stringify(
+          {
+            plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
+          },
+          null,
+          2,
+        ),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const server = await Config.get()
+      const tui = await TuiConfig.get()
+      const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
+      const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))
+
+      expect(serverPlugins).toEqual(tuiPlugins)
+      expect(serverPlugins).toContain("shared-plugin@2.0.0")
+      expect(serverPlugins).not.toContain("shared-plugin@1.0.0")
+
+      const serverOrigins = server.plugin_origins ?? []
+      const tuiOrigins = tui.plugin_origins ?? []
+      expect(serverOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(serverPlugins)
+      expect(tuiOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
+      expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope))
+    },
+  })
 })
 
 test("loads tui config with the same precedence order as server config paths", async () => {
@@ -476,9 +555,9 @@ test("loads managed tui config and gives it highest precedence", async () => {
       const config = await TuiConfig.get()
       expect(config.theme).toBe("managed-theme")
       expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
-      expect(config.plugin_records).toEqual([
+      expect(config.plugin_origins).toEqual([
         {
-          item: "shared-plugin@2.0.0",
+          spec: "shared-plugin@2.0.0",
           scope: "global",
           source: path.join(managedConfigDir, "tui.json"),
         },
@@ -540,9 +619,9 @@ test("supports tuple plugin specs with options in tui.json", async () => {
     fn: async () => {
       const config = await TuiConfig.get()
       expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
-      expect(config.plugin_records).toEqual([
+      expect(config.plugin_origins).toEqual([
         {
-          item: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
+          spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
           scope: "local",
           source: path.join(tmp.path, "tui.json"),
         },
@@ -580,14 +659,14 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a
         ["acme-plugin@2.0.0", { source: "project" }],
         ["second-plugin@3.0.0", { source: "project" }],
       ])
-      expect(config.plugin_records).toEqual([
+      expect(config.plugin_origins).toEqual([
         {
-          item: ["acme-plugin@2.0.0", { source: "project" }],
+          spec: ["acme-plugin@2.0.0", { source: "project" }],
           scope: "local",
           source: path.join(tmp.path, "tui.json"),
         },
         {
-          item: ["second-plugin@3.0.0", { source: "project" }],
+          spec: ["second-plugin@3.0.0", { source: "project" }],
           scope: "local",
           source: path.join(tmp.path, "tui.json"),
         },
@@ -619,14 +698,14 @@ test("tracks global and local plugin metadata in merged tui config", async () =>
     fn: async () => {
       const config = await TuiConfig.get()
       expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
-      expect(config.plugin_records).toEqual([
+      expect(config.plugin_origins).toEqual([
         {
-          item: "global-plugin@1.0.0",
+          spec: "global-plugin@1.0.0",
           scope: "global",
           source: path.join(Global.Path.config, "tui.json"),
         },
         {
-          item: "local-plugin@2.0.0",
+          spec: "local-plugin@2.0.0",
           scope: "local",
           source: path.join(tmp.path, "tui.json"),
         },

+ 3 - 3
packages/opencode/test/fixture/tui-runtime.ts

@@ -6,14 +6,14 @@ type PluginSpec = string | [string, Record<string, unknown>]
 
 export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
-  const plugin_records = plugin.map((item) => ({
-    item,
+  const plugin_origins = plugin.map((spec) => ({
+    spec,
     scope: "local" as const,
     source: path.join(dir, "tui.json"),
   }))
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin,
-    plugin_records,
+    plugin_origins,
   })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => dir)

+ 39 - 0
packages/opencode/test/plugin/install.test.ts

@@ -62,6 +62,7 @@ async function plugin(
     server?: Record<string, unknown>
     tui?: Record<string, unknown>
   },
+  themes?: string[],
 ) {
   const p = path.join(dir, "plugin")
   const server = kinds?.includes("server") ?? false
@@ -92,6 +93,7 @@ async function plugin(
         version: "1.0.0",
         ...(server ? { main: "./server.js" } : {}),
         ...(Object.keys(exports).length ? { exports } : {}),
+        ...(themes?.length ? { "oc-themes": themes } : {}),
       },
       null,
       2,
@@ -438,6 +440,43 @@ describe("plugin.install.task", () => {
     expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
   })
 
+  test("writes tui config for oc-themes-only packages", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, undefined, undefined, ["themes/forest.json"])
+    await fs.mkdir(path.join(target, "themes"), { recursive: true })
+    await Bun.write(path.join(target, "themes", "forest.json"), JSON.stringify({ theme: { text: "#fff" } }, null, 2))
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(true)
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true)
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+
+    const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
+    expect(tui.plugin).toEqual(["acme@1.2.3"])
+  })
+
+  test("returns false for oc-themes outside plugin directory", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, undefined, undefined, ["../outside.json"])
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(false)
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false)
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+  })
+
   test("force replaces version in both server and tui configs", async () => {
     await using tmp = await tmpdir()
     const target = await plugin(tmp.path, ["server", "tui"])

+ 300 - 0
packages/opencode/test/plugin/loader-shared.test.ts

@@ -9,6 +9,8 @@ const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
 process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
 
 const { Plugin } = await import("../../src/plugin/index")
+const { PluginLoader } = await import("../../src/plugin/loader")
+const { readPackageThemes } = await import("../../src/plugin/shared")
 const { Instance } = await import("../../src/project/instance")
 const { Npm } = await import("../../src/npm")
 const { Bus } = await import("../../src/bus")
@@ -833,4 +835,302 @@ export default {
       }
     }
   })
+
+  test("reads oc-themes from package manifest", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const mod = path.join(dir, "mod")
+        await fs.mkdir(path.join(mod, "themes"), { recursive: true })
+        await Bun.write(
+          path.join(mod, "package.json"),
+          JSON.stringify(
+            {
+              name: "acme-plugin",
+              version: "1.0.0",
+              "oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"],
+            },
+            null,
+            2,
+          ),
+        )
+
+        return { mod }
+      },
+    })
+
+    const file = path.join(tmp.extra.mod, "package.json")
+    const json = await Filesystem.readJson<Record<string, unknown>>(file)
+    const list = readPackageThemes("acme-plugin", {
+      dir: tmp.extra.mod,
+      pkg: file,
+      json,
+    })
+
+    expect(list).toEqual([
+      Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
+      Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
+    ])
+  })
+
+  test("handles no-entrypoint tui packages via missing callback", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const mod = path.join(dir, "mods", "acme-plugin")
+        await fs.mkdir(path.join(mod, "themes"), { recursive: true })
+        await Bun.write(
+          path.join(mod, "package.json"),
+          JSON.stringify(
+            {
+              name: "acme-plugin",
+              version: "1.0.0",
+              "oc-themes": ["themes/night.json"],
+            },
+            null,
+            2,
+          ),
+        )
+        await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
+        return { mod }
+      },
+    })
+
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+    const missing: string[] = []
+
+    try {
+      const loaded = await PluginLoader.loadExternal({
+        items: [
+          {
+            spec: "acme-plugin@1.0.0",
+            scope: "local" as const,
+            source: tmp.path,
+          },
+        ],
+        kind: "tui",
+        missing: async (item) => {
+          if (!item.pkg) return
+          const themes = readPackageThemes(item.spec, item.pkg)
+          if (!themes.length) return
+          return {
+            spec: item.spec,
+            target: item.target,
+            themes,
+          }
+        },
+        report: {
+          missing(_candidate, _retry, message) {
+            missing.push(message)
+          },
+        },
+      })
+
+      expect(loaded).toEqual([
+        {
+          spec: "acme-plugin@1.0.0",
+          target: tmp.extra.mod,
+          themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
+        },
+      ])
+      expect(missing).toHaveLength(0)
+    } finally {
+      install.mockRestore()
+    }
+  })
+
+  test("passes package metadata for entrypoint tui plugins", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const mod = path.join(dir, "mods", "acme-plugin")
+        await fs.mkdir(path.join(mod, "themes"), { recursive: true })
+        await Bun.write(
+          path.join(mod, "package.json"),
+          JSON.stringify(
+            {
+              name: "acme-plugin",
+              version: "1.0.0",
+              exports: {
+                "./tui": "./tui.js",
+              },
+              "oc-themes": ["themes/night.json"],
+            },
+            null,
+            2,
+          ),
+        )
+        await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n')
+        await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
+        return { mod }
+      },
+    })
+
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+
+    try {
+      const loaded = await PluginLoader.loadExternal({
+        items: [
+          {
+            spec: "acme-plugin@1.0.0",
+            scope: "local" as const,
+            source: tmp.path,
+          },
+        ],
+        kind: "tui",
+        finish: async (item) => {
+          if (!item.pkg) return
+          return {
+            spec: item.spec,
+            themes: readPackageThemes(item.spec, item.pkg),
+          }
+        },
+      })
+
+      expect(loaded).toEqual([
+        {
+          spec: "acme-plugin@1.0.0",
+          themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
+        },
+      ])
+    } finally {
+      install.mockRestore()
+    }
+  })
+
+  test("rejects oc-themes path traversal", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const mod = path.join(dir, "mod")
+        await fs.mkdir(mod, { recursive: true })
+        const file = path.join(mod, "package.json")
+        await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2))
+        return { mod, file }
+      },
+    })
+
+    const json = await Filesystem.readJson<Record<string, unknown>>(tmp.extra.file)
+    expect(() =>
+      readPackageThemes("acme", {
+        dir: tmp.extra.mod,
+        pkg: tmp.extra.file,
+        json,
+      }),
+    ).toThrow("outside plugin directory")
+  })
+
+  test("retries failed file plugins once after wait and keeps order", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const a = path.join(dir, "a")
+        const b = path.join(dir, "b")
+        const aSpec = pathToFileURL(a).href
+        const bSpec = pathToFileURL(b).href
+        await fs.mkdir(a, { recursive: true })
+        await fs.mkdir(b, { recursive: true })
+        return { a, b, aSpec, bSpec }
+      },
+    })
+
+    let wait = 0
+    const calls: Array<[string, boolean]> = []
+
+    const loaded = await PluginLoader.loadExternal({
+      items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
+        spec,
+        scope: "local" as const,
+        source: tmp.path,
+      })),
+      kind: "tui",
+      wait: async () => {
+        wait += 1
+        await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n")
+        await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n")
+      },
+      report: {
+        start(candidate, retry) {
+          calls.push([candidate.plan.spec, retry])
+        },
+      },
+    })
+
+    expect(wait).toBe(1)
+    expect(calls).toEqual([
+      [tmp.extra.aSpec, false],
+      [tmp.extra.bSpec, false],
+      [tmp.extra.aSpec, true],
+      [tmp.extra.bSpec, true],
+    ])
+    expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec])
+  })
+
+  test("retries file plugins when finish returns undefined", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = path.join(dir, "plugin.ts")
+        const spec = pathToFileURL(file).href
+        await Bun.write(file, "export default {}\n")
+        return { spec }
+      },
+    })
+
+    let wait = 0
+    let count = 0
+
+    const loaded = await PluginLoader.loadExternal({
+      items: [
+        {
+          spec: tmp.extra.spec,
+          scope: "local" as const,
+          source: tmp.path,
+        },
+      ],
+      kind: "tui",
+      wait: async () => {
+        wait += 1
+      },
+      finish: async (load, _item, retry) => {
+        count += 1
+        if (!retry) return
+        return {
+          retry,
+          spec: load.spec,
+        }
+      },
+    })
+
+    expect(wait).toBe(1)
+    expect(count).toBe(2)
+    expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }])
+  })
+
+  test("does not wait or retry npm plugin failures", async () => {
+    const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
+    let wait = 0
+    const errors: Array<[string, boolean]> = []
+
+    try {
+      const loaded = await PluginLoader.loadExternal({
+        items: [
+          {
+            spec: "acme-plugin@1.0.0",
+            scope: "local" as const,
+            source: "test",
+          },
+        ],
+        kind: "tui",
+        wait: async () => {
+          wait += 1
+        },
+        report: {
+          error(_candidate, retry, stage) {
+            errors.push([stage, retry])
+          },
+        },
+      })
+
+      expect(loaded).toEqual([])
+      expect(wait).toBe(0)
+      expect(errors).toEqual([["install", false]])
+    } finally {
+      install.mockRestore()
+    }
+  })
 })

+ 89 - 0
packages/opencode/test/util/filesystem.test.ts

@@ -83,6 +83,95 @@ describe("filesystem", () => {
     })
   })
 
+  describe("findUp()", () => {
+    test("keeps previous nearest-first behavior for single target", async () => {
+      await using tmp = await tmpdir()
+      const parent = path.join(tmp.path, "parent")
+      const child = path.join(parent, "child")
+      await fs.mkdir(child, { recursive: true })
+      await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8")
+      await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8")
+
+      const result = await Filesystem.findUp("marker", child, tmp.path)
+
+      expect(result).toEqual([path.join(parent, "marker"), path.join(tmp.path, "marker")])
+    })
+
+    test("respects stop boundary", async () => {
+      await using tmp = await tmpdir()
+      const parent = path.join(tmp.path, "parent")
+      const child = path.join(parent, "child")
+      await fs.mkdir(child, { recursive: true })
+      await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8")
+      await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8")
+
+      const result = await Filesystem.findUp("marker", child, parent)
+
+      expect(result).toEqual([path.join(parent, "marker")])
+    })
+
+    test("supports multiple targets with nearest-first default ordering", async () => {
+      await using tmp = await tmpdir()
+      const parent = path.join(tmp.path, "parent")
+      const child = path.join(parent, "child")
+      await fs.mkdir(child, { recursive: true })
+
+      await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8")
+      await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8")
+      await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8")
+
+      const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path)
+
+      expect(result).toEqual([
+        path.join(parent, "cfg.jsonc"),
+        path.join(tmp.path, "cfg.json"),
+        path.join(tmp.path, "cfg.jsonc"),
+      ])
+    })
+
+    test("supports rootFirst ordering for multiple targets", async () => {
+      await using tmp = await tmpdir()
+      const parent = path.join(tmp.path, "parent")
+      const child = path.join(parent, "child")
+      await fs.mkdir(child, { recursive: true })
+
+      await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8")
+      await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8")
+      await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8")
+
+      const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path, { rootFirst: true })
+
+      expect(result).toEqual([
+        path.join(tmp.path, "cfg.json"),
+        path.join(tmp.path, "cfg.jsonc"),
+        path.join(parent, "cfg.jsonc"),
+      ])
+    })
+
+    test("rootFirst preserves json then jsonc order per directory", async () => {
+      await using tmp = await tmpdir()
+      const project = path.join(tmp.path, "project")
+      const nested = path.join(project, "nested")
+      await fs.mkdir(nested, { recursive: true })
+
+      await fs.writeFile(path.join(tmp.path, "opencode.json"), "{}", "utf-8")
+      await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{}", "utf-8")
+      await fs.writeFile(path.join(project, "opencode.json"), "{}", "utf-8")
+      await fs.writeFile(path.join(project, "opencode.jsonc"), "{}", "utf-8")
+
+      const result = await Filesystem.findUp(["opencode.json", "opencode.jsonc"], nested, tmp.path, {
+        rootFirst: true,
+      })
+
+      expect(result).toEqual([
+        path.join(tmp.path, "opencode.json"),
+        path.join(tmp.path, "opencode.jsonc"),
+        path.join(project, "opencode.json"),
+        path.join(project, "opencode.jsonc"),
+      ])
+    })
+  })
+
   describe("readText()", () => {
     test("reads file content", async () => {
       await using tmp = await tmpdir()