Browse Source

Refactor into plugin loader and do not enforce (#20112)

Sebastian 2 weeks ago
parent
commit
fa95a61c4e

+ 8 - 2
packages/opencode/specs/tui-plugins.md

@@ -84,12 +84,18 @@ export default plugin
 - TUI shape is `default export { id?, tui }`; including `server` is rejected.
 - A single module cannot export both `server` and `tui`.
 - `tui` signature is `(api, options, meta) => Promise<void>`.
-- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
+- If package `exports` contains `./tui`, the loader resolves that entrypoint.
+- 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 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.
 - Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
-- If a path spec points at a directory, that directory must have `package.json` with `main`.
+- If a path spec points at a directory, server loading can use `package.json` `main`.
+- TUI path loading never uses `package.json` `main`.
+- Legacy compatibility: path specs like `./plugin` can resolve to `./plugin/index.ts` (or `index.js`) when `package.json` is missing.
+- The `./plugin -> ./plugin/index.*` fallback applies to both server and TUI v1 loading.
 - There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
 
 ## Package manifest and install

+ 96 - 130
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

@@ -18,17 +18,8 @@ import { Log } from "@/util/log"
 import { errorData, errorMessage } from "@/util/error"
 import { isRecord } from "@/util/record"
 import { Instance } from "@/project/instance"
-import {
-  checkPluginCompatibility,
-  isDeprecatedPlugin,
-  pluginSource,
-  readPluginId,
-  readV1Plugin,
-  resolvePluginEntrypoint,
-  resolvePluginId,
-  resolvePluginTarget,
-  type PluginSource,
-} from "@/plugin/shared"
+import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared"
+import { PluginLoader } from "@/plugin/loader"
 import { PluginMeta } from "@/plugin/meta"
 import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
 import { hasTheme, upsertTheme } from "../context/theme"
@@ -36,13 +27,12 @@ import { Global } from "@/global"
 import { Filesystem } from "@/util/filesystem"
 import { Process } from "@/util/process"
 import { Flag } from "@/flag/flag"
-import { Installation } from "@/installation"
 import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
 import { setupSlots, Slot as View } from "./slots"
 import type { HostPluginApi, HostSlots } from "./slots"
 
 type PluginLoad = {
-  item?: Config.PluginSpec
+  options: Config.PluginOptions | undefined
   spec: string
   target: string
   retry: boolean
@@ -67,7 +57,6 @@ type PluginEntry = {
   meta: TuiPluginMeta
   themes: Record<string, PluginMeta.Theme>
   plugin: TuiPlugin
-  options: Config.PluginOptions | undefined
   enabled: boolean
   scope?: PluginScope
 }
@@ -78,13 +67,7 @@ type RuntimeState = {
   slots: HostSlots
   plugins: PluginEntry[]
   plugins_by_id: Map<string, PluginEntry>
-  pending: Map<
-    string,
-    {
-      item: Config.PluginSpec
-      meta: TuiConfig.PluginMeta
-    }
-  >
+  pending: Map<string, TuiConfig.PluginRecord>
 }
 
 const log = Log.create({ service: "tui.plugin" })
@@ -239,73 +222,76 @@ function createThemeInstaller(
   }
 }
 
-async function loadExternalPlugin(
-  item: Config.PluginSpec,
-  meta: TuiConfig.PluginMeta | undefined,
-  retry = false,
-): Promise<PluginLoad | undefined> {
-  const spec = Config.pluginSpecifier(item)
-  if (isDeprecatedPlugin(spec)) return
-  log.info("loading tui plugin", { path: spec, retry })
-  const resolved = await resolvePluginTarget(spec).catch((error) => {
-    fail("failed to resolve tui plugin", { path: spec, retry, error })
-    return
-  })
-  if (!resolved) return
+async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
+  const plan = PluginLoader.plan(cfg.item)
+  if (plan.deprecated) return
 
-  const source = pluginSource(spec)
-  if (source === "npm") {
-    const ok = await checkPluginCompatibility(resolved, Installation.VERSION)
-      .then(() => true)
-      .catch((error) => {
-        fail("tui plugin incompatible", { path: spec, retry, error })
-        return false
-      })
-    if (!ok) return
+  log.info("loading tui plugin", { path: plan.spec, retry })
+  const resolved = await PluginLoader.resolve(plan, "tui")
+  if (!resolved.ok) {
+    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
   }
 
-  const target = resolved
-  if (!meta) {
-    fail("missing tui plugin metadata", {
-      path: spec,
+  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
   }
 
-  const root = resolveRoot(source === "file" ? spec : target)
-  const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
-    fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
-    return
-  })
-  if (!entry) return
-
-  const mod = await import(entry)
-    .then((raw) => {
-      return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule
+  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: spec, target: entry, retry, error })
+      fail("failed to load tui plugin", {
+        path: plan.spec,
+        target: loaded.value.entry,
+        retry,
+        error,
+      })
       return
     })
   if (!mod) return
 
-  const id = await resolvePluginId(source, spec, target, readPluginId(mod.id, spec)).catch((error) => {
-    fail("failed to load tui plugin", { path: spec, target, retry, error })
+  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 {
-    item,
-    spec,
-    target,
+    options: plan.options,
+    spec: plan.spec,
+    target: loaded.value.target,
     retry,
-    source,
+    source: loaded.value.source,
     id,
     module: mod,
-    theme_meta: meta,
-    theme_root: root,
+    theme_meta: {
+      scope: cfg.scope,
+      source: cfg.source,
+    },
+    theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target),
   }
 }
 
@@ -343,6 +329,7 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
   const target = spec
 
   return {
+    options: undefined,
     spec,
     target,
     retry: false,
@@ -488,7 +475,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 plugin.plugin(api, plugin.options, plugin.meta)
+      await plugin.plugin(api, plugin.load.options, plugin.meta)
       return true
     })
     .catch((error) => {
@@ -613,21 +600,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
   }
 }
 
-function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta, themes: Record<string, PluginMeta.Theme> = {}) {
-  const options = load.item ? Config.pluginOptions(load.item) : undefined
-  return [
-    {
-      id: load.id,
-      load,
-      meta,
-      themes,
-      plugin: load.module.tui,
-      options,
-      enabled: true,
-    },
-  ]
-}
-
 function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
   if (state.plugins_by_id.has(plugin.id)) {
     fail("duplicate tui plugin id", {
@@ -651,12 +623,8 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
   }
 }
 
-async function resolveExternalPlugins(
-  list: Config.PluginSpec[],
-  wait: () => Promise<void>,
-  meta: (item: Config.PluginSpec) => TuiConfig.PluginMeta | undefined,
-) {
-  const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item, meta(item))))
+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
 
@@ -665,13 +633,12 @@ async function resolveExternalPlugins(
     if (!entry) {
       const item = list[i]
       if (!item) continue
-      const spec = Config.pluginSpecifier(item)
-      if (pluginSource(spec) !== "file") continue
+      if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
       deps ??= wait().catch((error) => {
         log.warn("failed waiting for tui plugin dependencies", { error })
       })
       await deps
-      entry = await loadExternalPlugin(item, meta(item), true)
+      entry = await loadExternalPlugin(item, true)
     }
     if (!entry) continue
     ready.push(entry)
@@ -713,20 +680,27 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
 
     const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
     const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
-    for (const plugin of collectPluginEntries(entry, row, themes)) {
-      if (!addPluginEntry(state, plugin)) {
-        ok = false
-        continue
-      }
-      plugins.push(plugin)
+    const plugin: PluginEntry = {
+      id: entry.id,
+      load: entry,
+      meta: row,
+      themes,
+      plugin: entry.module.tui,
+      enabled: true,
+    }
+    if (!addPluginEntry(state, plugin)) {
+      ok = false
+      continue
     }
+    plugins.push(plugin)
   }
 
   return { plugins, ok }
 }
 
-function defaultPluginMeta(state: RuntimeState): TuiConfig.PluginMeta {
+function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
   return {
+    item: spec,
     scope: "local",
     source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
   }
@@ -764,36 +738,28 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
   const spec = raw.trim()
   if (!spec) return false
 
-  const pending = state.pending.get(spec)
-  const item = pending?.item ?? spec
-  const nextSpec = Config.pluginSpecifier(item)
-  if (state.plugins.some((plugin) => plugin.load.spec === nextSpec)) {
+  const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
+  const next = Config.pluginSpecifier(cfg.item)
+  if (state.plugins.some((plugin) => plugin.load.spec === next)) {
     state.pending.delete(spec)
     return true
   }
 
-  const meta = pending?.meta ?? defaultPluginMeta(state)
-
   const ready = await Instance.provide({
     directory: state.directory,
-    fn: () =>
-      resolveExternalPlugins(
-        [item],
-        () => TuiConfig.waitForDependencies(),
-        () => meta,
-      ),
+    fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
   }).catch((error) => {
-    fail("failed to add tui plugin", { path: nextSpec, error })
+    fail("failed to add tui plugin", { path: next, error })
     return [] as PluginLoad[]
   })
   if (!ready.length) {
-    fail("failed to add tui plugin", { path: nextSpec })
+    fail("failed to add tui plugin", { path: next })
     return false
   }
 
   const first = ready[0]
   if (!first) {
-    fail("failed to add tui plugin", { path: nextSpec })
+    fail("failed to add tui plugin", { path: next })
     return false
   }
   if (state.plugins_by_id.has(first.id)) {
@@ -810,7 +776,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
 
   if (ok) state.pending.delete(spec)
   if (!ok) {
-    fail("failed to add tui plugin", { path: nextSpec })
+    fail("failed to add tui plugin", { path: next })
   }
   return ok
 }
@@ -893,12 +859,11 @@ 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
     state.pending.set(spec, {
-      item: tui.opts ? [spec, tui.opts] : spec,
-      meta: {
-        scope: global ? "global" : "local",
-        source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
-      },
+      item,
+      scope: global ? "global" : "local",
+      source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
     })
   }
 
@@ -981,25 +946,26 @@ export namespace TuiPluginRuntime {
       directory: cwd,
       fn: async () => {
         const config = await TuiConfig.get()
-        const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? [])
-        if (Flag.OPENCODE_PURE && config.plugin?.length) {
-          log.info("skipping external tui plugins in pure mode", { count: config.plugin.length })
+        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 })
         }
 
         for (const item of INTERNAL_TUI_PLUGINS) {
           log.info("loading internal tui plugin", { id: item.id })
           const entry = loadInternalPlugin(item)
           const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
-          for (const plugin of collectPluginEntries(entry, meta)) {
-            addPluginEntry(next, plugin)
-          }
+          addPluginEntry(next, {
+            id: entry.id,
+            load: entry,
+            meta,
+            themes: {},
+            plugin: entry.module.tui,
+            enabled: true,
+          })
         }
 
-        const ready = await resolveExternalPlugins(
-          plugins,
-          () => TuiConfig.waitForDependencies(),
-          (item) => config.plugin_meta?.[Config.pluginSpecifier(item)],
-        )
+        const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
         await addExternalPluginEntries(next, ready)
 
         applyInitialPluginEnabledState(next, config)

+ 12 - 28
packages/opencode/src/config/config.ts

@@ -1,7 +1,6 @@
 import { Log } from "../util/log"
 import path from "path"
 import { pathToFileURL } from "url"
-import { createRequire } from "module"
 import os from "os"
 import z from "zod"
 import { ModelsDev } from "../provider/models"
@@ -366,33 +365,18 @@ export namespace Config {
   export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
     const spec = pluginSpecifier(plugin)
     if (!isPathPluginSpec(spec)) return plugin
-    if (spec.startsWith("file://")) {
-      const resolved = await resolvePathPluginTarget(spec).catch(() => spec)
-      if (Array.isArray(plugin)) return [resolved, plugin[1]]
-      return resolved
-    }
-    if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) {
-      const base = pathToFileURL(spec).href
-      const resolved = await resolvePathPluginTarget(base).catch(() => base)
-      if (Array.isArray(plugin)) return [resolved, plugin[1]]
-      return resolved
-    }
-    try {
-      const base = import.meta.resolve!(spec, configFilepath)
-      const resolved = await resolvePathPluginTarget(base).catch(() => base)
-      if (Array.isArray(plugin)) return [resolved, plugin[1]]
-      return resolved
-    } catch {
-      try {
-        const require = createRequire(configFilepath)
-        const base = pathToFileURL(require.resolve(spec)).href
-        const resolved = await resolvePathPluginTarget(base).catch(() => base)
-        if (Array.isArray(plugin)) return [resolved, plugin[1]]
-        return resolved
-      } catch {
-        return plugin
-      }
-    }
+
+    const base = path.dirname(configFilepath)
+    const file = (() => {
+      if (spec.startsWith("file://")) return spec
+      if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href
+      return pathToFileURL(path.resolve(base, spec)).href
+    })()
+
+    const resolved = await resolvePathPluginTarget(file).catch(() => file)
+
+    if (Array.isArray(plugin)) return [resolved, plugin[1]]
+    return resolved
   }
 
   /**

+ 15 - 5
packages/opencode/src/config/tui.ts

@@ -22,6 +22,12 @@ export namespace TuiConfig {
     source: string
   }
 
+  export type PluginRecord = {
+    item: Config.PluginSpec
+    scope: PluginMeta["scope"]
+    source: string
+  }
+
   type PluginEntry = {
     item: Config.PluginSpec
     meta: PluginMeta
@@ -33,7 +39,8 @@ export namespace TuiConfig {
   }
 
   export type Info = z.output<typeof Info> & {
-    plugin_meta?: Record<string, PluginMeta>
+    // Internal resolved plugin list used by runtime loading.
+    plugin_records?: PluginRecord[]
   }
 
   function pluginScope(file: string): PluginMeta["scope"] {
@@ -149,10 +156,13 @@ export namespace TuiConfig {
 
     const merged = dedupePlugins(acc.entries)
     acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
-    acc.result.plugin = merged.map((item) => item.item)
-    acc.result.plugin_meta = merged.length
-      ? Object.fromEntries(merged.map((item) => [Config.pluginSpecifier(item.item), item.meta]))
-      : undefined
+    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) {

+ 84 - 101
packages/opencode/src/plugin/index.ts

@@ -14,19 +14,8 @@ import { Effect, Layer, ServiceMap, Stream } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
 import { errorMessage } from "@/util/error"
-import { Installation } from "@/installation"
-import {
-  checkPluginCompatibility,
-  isDeprecatedPlugin,
-  parsePluginSpecifier,
-  pluginSource,
-  readPluginId,
-  readV1Plugin,
-  resolvePluginEntrypoint,
-  resolvePluginId,
-  resolvePluginTarget,
-  type PluginSource,
-} from "./shared"
+import { PluginLoader } from "./loader"
+import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
 
 export namespace Plugin {
   const log = Log.create({ service: "plugin" })
@@ -36,11 +25,7 @@ export namespace Plugin {
   }
 
   type Loaded = {
-    item: Config.PluginSpec
-    spec: string
-    target: string
-    source: PluginSource
-    mod: Record<string, unknown>
+    row: PluginLoader.Loaded
   }
 
   // Hook names that follow the (input, output) => Promise<void> trigger pattern
@@ -93,91 +78,22 @@ export namespace Plugin {
     return result
   }
 
-  async function resolvePlugin(spec: string) {
-    const parsed = parsePluginSpecifier(spec)
-    const target = await resolvePluginTarget(spec, parsed).catch((err) => {
-      const cause = err instanceof Error ? err.cause : err
-      const detail = errorMessage(cause ?? err)
-      log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
-      Bus.publish(Session.Event.Error, {
-        error: new NamedError.Unknown({
-          message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
-        }).toObject(),
-      })
-      return ""
-    })
-    if (!target) return
-    return target
-  }
-
-  async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
-    const spec = Config.pluginSpecifier(item)
-    if (isDeprecatedPlugin(spec)) return
-    log.info("loading plugin", { path: spec })
-    const resolved = await resolvePlugin(spec)
-    if (!resolved) return
-
-    const source = pluginSource(spec)
-    if (source === "npm") {
-      const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
-        .then(() => false)
-        .catch((err) => {
-          const message = errorMessage(err)
-          log.warn("plugin incompatible", { path: spec, error: message })
-          Bus.publish(Session.Event.Error, {
-            error: new NamedError.Unknown({
-              message: `Plugin ${spec} skipped: ${message}`,
-            }).toObject(),
-          })
-          return true
-        })
-      if (incompatible) return
-    }
-
-    const target = resolved
-    const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
-      const message = errorMessage(err)
-      log.error("failed to resolve plugin server entry", { path: spec, target, error: message })
-      Bus.publish(Session.Event.Error, {
-        error: new NamedError.Unknown({
-          message: `Failed to load plugin ${spec}: ${message}`,
-        }).toObject(),
-      })
-      return
-    })
-    if (!entry) return
-
-    const mod = await import(entry).catch((err) => {
-      const message = errorMessage(err)
-      log.error("failed to load plugin", { path: spec, target: entry, error: message })
-      Bus.publish(Session.Event.Error, {
-        error: new NamedError.Unknown({
-          message: `Failed to load plugin ${spec}: ${message}`,
-        }).toObject(),
-      })
-      return
-    })
-    if (!mod) return
-
-    return {
-      item,
-      spec,
-      target,
-      source,
-      mod,
-    }
-  }
-
   async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
-    const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
+    const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect")
     if (plugin) {
-      await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec))
-      hooks.push(await (plugin as PluginModule).server(input, Config.pluginOptions(load.item)))
+      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))
       return
     }
 
-    for (const server of getLegacyPlugins(load.mod)) {
-      hooks.push(await server(input, Config.pluginOptions(load.item)))
+    for (const server of getLegacyPlugins(load.row.mod)) {
+      hooks.push(await server(input, load.row.options))
     }
   }
 
@@ -232,7 +148,74 @@ export namespace Plugin {
           }
           if (plugins.length) yield* config.waitForDependencies()
 
-          const loaded = yield* Effect.promise(() => Promise.all(plugins.map((item) => prepPlugin(item))))
+          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) {
+                  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(),
+                    })
+                    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(),
+                    })
+                    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,
+                }
+              }),
+            ),
+          )
           for (const load of loaded) {
             if (!load) continue
 
@@ -242,14 +225,14 @@ export namespace Plugin {
               try: () => applyPlugin(load, input, hooks),
               catch: (err) => {
                 const message = errorMessage(err)
-                log.error("failed to load plugin", { path: load.spec, error: message })
+                log.error("failed to load plugin", { path: load.row.spec, error: message })
                 return message
               },
             }).pipe(
               Effect.catch((message) =>
                 bus.publish(Session.Event.Error, {
                   error: new NamedError.Unknown({
-                    message: `Failed to load plugin ${load.spec}: ${message}`,
+                    message: `Failed to load plugin ${load.row.spec}: ${message}`,
                   }).toObject(),
                 }),
               ),

+ 135 - 0
packages/opencode/src/plugin/loader.ts

@@ -0,0 +1,135 @@
+import { Config } from "@/config/config"
+import { Installation } from "@/installation"
+import {
+  checkPluginCompatibility,
+  createPluginEntry,
+  isDeprecatedPlugin,
+  resolvePluginTarget,
+  type PluginKind,
+  type PluginPackage,
+  type PluginSource,
+} from "./shared"
+
+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 Loaded = Resolved & {
+    mod: Record<string, unknown>
+  }
+
+  export function plan(item: Config.PluginSpec): Plan {
+    const spec = Config.pluginSpecifier(item)
+    return {
+      item,
+      spec,
+      options: Config.pluginOptions(item),
+      deprecated: isDeprecatedPlugin(spec),
+    }
+  }
+
+  export async function resolve(
+    plan: Plan,
+    kind: PluginKind,
+  ): Promise<
+    { ok: true; value: Resolved } | { 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`),
+      }
+    }
+
+    let base
+    try {
+      base = await createPluginEntry(plan.spec, target, kind)
+    } catch (error) {
+      return {
+        ok: false,
+        stage: "entry",
+        error,
+      }
+    }
+
+    if (!base.entry) {
+      return {
+        ok: false,
+        stage: "entry",
+        error: new Error(`Plugin ${plan.spec} entry is empty`),
+      }
+    }
+
+    if (base.source === "npm") {
+      try {
+        await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
+      } catch (error) {
+        return {
+          ok: false,
+          stage: "compatibility",
+          error,
+        }
+      }
+    }
+
+    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 }> {
+    let mod
+    try {
+      mod = await import(row.entry)
+    } catch (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,
+      },
+    }
+  }
+}

+ 135 - 30
packages/opencode/src/plugin/shared.ts

@@ -23,13 +23,25 @@ export type PluginSource = "file" | "npm"
 export type PluginKind = "server" | "tui"
 type PluginMode = "strict" | "detect"
 
-export function pluginSource(spec: string): PluginSource {
-  return spec.startsWith("file://") ? "file" : "npm"
+export type PluginPackage = {
+  dir: string
+  pkg: string
+  json: Record<string, unknown>
+}
+
+export type PluginEntry = {
+  spec: string
+  source: PluginSource
+  target: string
+  pkg?: PluginPackage
+  entry: string
 }
 
-function hasEntrypoint(json: Record<string, unknown>, kind: PluginKind) {
-  if (!isRecord(json.exports)) return false
-  return `./${kind}` in json.exports
+const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
+
+export function pluginSource(spec: string): PluginSource {
+  if (isPathPluginSpec(spec)) return "file"
+  return "npm"
 }
 
 function resolveExportPath(raw: string, dir: string) {
@@ -48,26 +60,97 @@ function extractExportValue(value: unknown): string | undefined {
   return undefined
 }
 
-export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) {
-  const pkg = await readPluginPackage(target).catch(() => undefined)
-  if (!pkg) return target
-  if (!hasEntrypoint(pkg.json, kind)) return target
-
-  const exports = pkg.json.exports
-  if (!isRecord(exports)) return target
-  const raw = extractExportValue(exports[`./${kind}`])
-  if (!raw) return target
+function packageMain(pkg: PluginPackage) {
+  const value = pkg.json.main
+  if (typeof value !== "string") return
+  const next = value.trim()
+  if (!next) return
+  return next
+}
 
+function resolvePackagePath(spec: string, raw: string, kind: PluginKind, 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
 }
 
+function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
+  const exports = pkg.json.exports
+  if (isRecord(exports)) {
+    const raw = extractExportValue(exports[`./${kind}`])
+    if (raw) return resolvePackagePath(spec, raw, kind, pkg)
+  }
+
+  if (kind !== "server") return
+  const main = packageMain(pkg)
+  if (!main) return
+  return resolvePackagePath(spec, main, kind, pkg)
+}
+
+function targetPath(target: string) {
+  if (target.startsWith("file://")) return fileURLToPath(target)
+  if (path.isAbsolute(target) || /^[A-Za-z]:[\\/]/.test(target)) return target
+}
+
+async function resolveDirectoryIndex(dir: string) {
+  for (const name of INDEX_FILES) {
+    const file = path.join(dir, name)
+    if (await Filesystem.exists(file)) return file
+  }
+}
+
+async function resolveTargetDirectory(target: string) {
+  const file = targetPath(target)
+  if (!file) return
+  const stat = await Filesystem.stat(file)
+  if (!stat?.isDirectory()) return
+  return file
+}
+
+async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind, pkg?: PluginPackage) {
+  const source = pluginSource(spec)
+  const hit =
+    pkg ?? (source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined))
+  if (!hit) return target
+
+  const entry = resolvePackageEntrypoint(spec, kind, hit)
+  if (entry) return entry
+
+  const dir = await resolveTargetDirectory(target)
+
+  if (kind === "tui") {
+    if (source === "file" && dir) {
+      const index = await resolveDirectoryIndex(dir)
+      if (index) return pathToFileURL(index).href
+    }
+
+    if (source === "npm") {
+      throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"]`)
+    }
+
+    if (dir) {
+      throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"] or include index file`)
+    }
+
+    return target
+  }
+
+  if (dir && isRecord(hit.json.exports)) {
+    if (source === "file") {
+      const index = await resolveDirectoryIndex(dir)
+      if (index) return pathToFileURL(index).href
+    }
+
+    throw new TypeError(`Plugin ${spec} must define package.json exports["./server"] or package.json main`)
+  }
+
+  return target
+}
+
 export function isPathPluginSpec(spec: string) {
   return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
 }
@@ -81,19 +164,21 @@ export async function resolvePathPluginTarget(spec: string) {
     return pathToFileURL(file).href
   }
 
-  const pkg = await Filesystem.readJson<Record<string, unknown>>(path.join(file, "package.json")).catch(() => undefined)
-  if (!pkg) throw new Error(`Plugin directory ${file} is missing package.json`)
-  if (typeof pkg.main !== "string" || !pkg.main.trim()) {
-    throw new Error(`Plugin directory ${file} must define package.json main`)
+  if (await Filesystem.exists(path.join(file, "package.json"))) {
+    return pathToFileURL(file).href
   }
-  return pathToFileURL(path.resolve(file, pkg.main)).href
+
+  const index = await resolveDirectoryIndex(file)
+  if (index) return pathToFileURL(index).href
+
+  throw new Error(`Plugin directory ${file} is missing package.json or index file`)
 }
 
-export async function checkPluginCompatibility(target: string, opencodeVersion: string) {
+export async function checkPluginCompatibility(target: string, opencodeVersion: string, pkg?: PluginPackage) {
   if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return
-  const pkg = await readPluginPackage(target).catch(() => undefined)
-  if (!pkg) return
-  const engines = pkg.json.engines
+  const hit = pkg ?? (await readPluginPackage(target).catch(() => undefined))
+  if (!hit) return
+  const engines = hit.json.engines
   if (!isRecord(engines)) return
   const range = engines.opencode
   if (typeof range !== "string") return
@@ -107,7 +192,7 @@ export async function resolvePluginTarget(spec: string, parsed = parsePluginSpec
   return BunProc.install(parsed.pkg, parsed.version)
 }
 
-export async function readPluginPackage(target: string) {
+export async function readPluginPackage(target: string): Promise<PluginPackage> {
   const file = target.startsWith("file://") ? fileURLToPath(target) : target
   const stat = await Filesystem.stat(file)
   const dir = stat?.isDirectory() ? file : path.dirname(file)
@@ -116,6 +201,20 @@ export async function readPluginPackage(target: string) {
   return { dir, pkg, json }
 }
 
+export async function createPluginEntry(spec: string, target: string, kind: PluginKind): Promise<PluginEntry> {
+  const source = pluginSource(spec)
+  const pkg =
+    source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined)
+  const entry = await resolvePluginEntrypoint(spec, target, kind, pkg)
+  return {
+    spec,
+    source,
+    target,
+    pkg,
+    entry,
+  }
+}
+
 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}`)
@@ -158,15 +257,21 @@ export function readV1Plugin(
   return value
 }
 
-export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
+export async function resolvePluginId(
+  source: PluginSource,
+  spec: string,
+  target: string,
+  id: string | undefined,
+  pkg?: PluginPackage,
+) {
   if (source === "file") {
     if (id) return id
     throw new TypeError(`Path plugin ${spec} must export id`)
   }
   if (id) return id
-  const pkg = await readPluginPackage(target)
-  if (typeof pkg.json.name !== "string" || !pkg.json.name.trim()) {
-    throw new TypeError(`Plugin package ${pkg.pkg} is missing name`)
+  const hit = pkg ?? (await readPluginPackage(target))
+  if (typeof hit.json.name !== "string" || !hit.json.name.trim()) {
+    throw new TypeError(`Plugin package ${hit.pkg} is missing name`)
   }
-  return pkg.json.name.trim()
+  return hit.json.name.trim()
 }

+ 1 - 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_meta: undefined,
+    plugin_records: undefined,
   })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)

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

@@ -48,7 +48,7 @@ test("installs plugin without loading it", async () => {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
   let cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
     plugin: [],
-    plugin_meta: undefined,
+    plugin_records: undefined,
   }
   const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
@@ -68,12 +68,13 @@ test("installs plugin without loading it", async () => {
     await TuiPluginRuntime.init(api)
     cfg = {
       plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
-      plugin_meta: {
-        [tmp.extra.spec]: {
+      plugin_records: [
+        {
+          item: [tmp.extra.spec, { marker: tmp.extra.marker }],
           scope: "local",
           source: path.join(tmp.path, "tui.json"),
         },
-      },
+      ],
     }
 
     const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)

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

@@ -1,6 +1,7 @@
 import { expect, spyOn, test } from "bun:test"
 import fs from "fs/promises"
 import path from "path"
+import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { TuiConfig } from "../../../src/config/tui"
@@ -45,9 +46,13 @@ 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_meta: {
-      [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
-    },
+    plugin_records: [
+      {
+        item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    ],
   })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -70,6 +75,65 @@ test("loads npm tui plugin from package ./tui export", async () => {
   }
 })
 
+test("does not use npm package exports dot for tui entry", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const mod = path.join(dir, "mods", "acme-plugin")
+      const marker = path.join(dir, "dot-called.txt")
+      await fs.mkdir(mod, { recursive: true })
+
+      await Bun.write(
+        path.join(mod, "package.json"),
+        JSON.stringify({
+          name: "acme-plugin",
+          type: "module",
+          exports: { ".": "./index.js" },
+        }),
+      )
+      await Bun.write(
+        path.join(mod, "index.js"),
+        `export default {
+  id: "demo.dot",
+  tui: async () => {
+    await Bun.write(${JSON.stringify(marker)}, "called")
+  },
+}
+`,
+      )
+
+      return { mod, marker, spec: "[email protected]" }
+    },
+  })
+
+  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: [
+      {
+        item: tmp.extra.spec,
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    ],
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+  const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+    expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
+  } finally {
+    await TuiPluginRuntime.dispose()
+    install.mockRestore()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})
+
 test("rejects npm tui export that resolves outside plugin directory", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
@@ -107,9 +171,13 @@ 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_meta: {
-      [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
-    },
+    plugin_records: [
+      {
+        item: tmp.extra.spec,
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    ],
   })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -166,10 +234,73 @@ 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_meta: {
-      [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
+    plugin_records: [
+      {
+        item: tmp.extra.spec,
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    ],
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+  const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+    expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
+  } finally {
+    await TuiPluginRuntime.dispose()
+    install.mockRestore()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})
+
+test("does not use npm package main for tui entry", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const mod = path.join(dir, "mods", "acme-plugin")
+      const marker = path.join(dir, "main-called.txt")
+      await fs.mkdir(mod, { recursive: true })
+
+      await Bun.write(
+        path.join(mod, "package.json"),
+        JSON.stringify({
+          name: "acme-plugin",
+          type: "module",
+          main: "./index.js",
+        }),
+      )
+      await Bun.write(
+        path.join(mod, "index.js"),
+        `export default {
+  id: "demo.main",
+  tui: async () => {
+    await Bun.write(${JSON.stringify(marker)}, "called")
+  },
+}
+`,
+      )
+
+      return { mod, marker, spec: "[email protected]" }
     },
   })
+
+  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: [
+      {
+        item: tmp.extra.spec,
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    ],
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
@@ -187,3 +318,169 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
+
+test("does not use directory package main for tui entry", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const mod = path.join(dir, "mods", "dir-plugin")
+      const spec = pathToFileURL(mod).href
+      const marker = path.join(dir, "dir-main-called.txt")
+      await fs.mkdir(mod, { recursive: true })
+
+      await Bun.write(
+        path.join(mod, "package.json"),
+        JSON.stringify({
+          name: "dir-plugin",
+          type: "module",
+          main: "./main.js",
+        }),
+      )
+      await Bun.write(
+        path.join(mod, "main.js"),
+        `export default {
+  id: "demo.dir.main",
+  tui: async () => {
+    await Bun.write(${JSON.stringify(marker)}, "called")
+  },
+}
+`,
+      )
+
+      return { marker, spec }
+    },
+  })
+
+  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: [
+      {
+        item: tmp.extra.spec,
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    ],
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+    expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
+  } finally {
+    await TuiPluginRuntime.dispose()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})
+
+test("uses directory index fallback for tui when package.json is missing", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const mod = path.join(dir, "mods", "dir-index")
+      const spec = pathToFileURL(mod).href
+      const marker = path.join(dir, "dir-index-called.txt")
+      await fs.mkdir(mod, { recursive: true })
+      await Bun.write(
+        path.join(mod, "index.ts"),
+        `export default {
+  id: "demo.dir.index",
+  tui: async () => {
+    await Bun.write(${JSON.stringify(marker)}, "called")
+  },
+}
+`,
+      )
+      return { marker, spec }
+    },
+  })
+
+  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: [
+      {
+        item: tmp.extra.spec,
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    ],
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
+    expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true)
+  } finally {
+    await TuiPluginRuntime.dispose()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})
+
+test("uses npm package name when tui plugin id is omitted", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const mod = path.join(dir, "mods", "acme-plugin")
+      const marker = path.join(dir, "name-id-called.txt")
+      await fs.mkdir(mod, { recursive: true })
+
+      await Bun.write(
+        path.join(mod, "package.json"),
+        JSON.stringify({
+          name: "acme-plugin",
+          type: "module",
+          exports: { ".": "./index.js", "./tui": "./tui.js" },
+        }),
+      )
+      await Bun.write(path.join(mod, "index.js"), "export default {}\n")
+      await Bun.write(
+        path.join(mod, "tui.js"),
+        `export default {
+  tui: async (_api, options) => {
+    if (!options?.marker) return
+    await Bun.write(options.marker, "called")
+  },
+}
+`,
+      )
+
+      return { mod, marker, spec: "[email protected]" }
+    },
+  })
+
+  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: [
+      {
+        item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    ],
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+  const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
+    expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
+  } finally {
+    await TuiPluginRuntime.dispose()
+    install.mockRestore()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})

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

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

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

@@ -468,10 +468,18 @@ test("continues loading when a plugin is missing config metadata", async () => {
       [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
       tmp.extra.bareSpec,
     ],
-    plugin_meta: {
-      [tmp.extra.goodSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
-      [tmp.extra.bareSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
-    },
+    plugin_records: [
+      {
+        item: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+      {
+        item: tmp.extra.bareSpec,
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    ],
   })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -493,6 +501,84 @@ test("continues loading when a plugin is missing config metadata", async () => {
   }
 })
 
+test("initializes external tui plugins in config order", async () => {
+  const globalJson = path.join(Global.Path.config, "tui.json")
+  const globalJsonc = path.join(Global.Path.config, "tui.jsonc")
+  const backupJson = await Bun.file(globalJson)
+    .text()
+    .catch(() => undefined)
+  const backupJsonc = await Bun.file(globalJsonc)
+    .text()
+    .catch(() => undefined)
+
+  await fs.rm(globalJson, { force: true }).catch(() => {})
+  await fs.rm(globalJsonc, { force: true }).catch(() => {})
+
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const a = path.join(dir, "order-a.ts")
+      const b = path.join(dir, "order-b.ts")
+      const aSpec = pathToFileURL(a).href
+      const bSpec = pathToFileURL(b).href
+      const marker = path.join(dir, "tui-order.txt")
+
+      await Bun.write(
+        a,
+        `import fs from "fs/promises"
+
+export default {
+  id: "demo.tui.order.a",
+  tui: async () => {
+    await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
+    await Bun.sleep(25)
+    await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
+  },
+}
+`,
+      )
+      await Bun.write(
+        b,
+        `import fs from "fs/promises"
+
+export default {
+  id: "demo.tui.order.b",
+  tui: async () => {
+    await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
+  },
+}
+`,
+      )
+      await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
+
+      return { marker }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
+    expect(lines).toEqual(["a-start", "a-end", "b"])
+  } finally {
+    await TuiPluginRuntime.dispose()
+    cwd.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+
+    if (backupJson === undefined) {
+      await fs.rm(globalJson, { force: true }).catch(() => {})
+    } else {
+      await Bun.write(globalJson, backupJson)
+    }
+    if (backupJsonc === undefined) {
+      await fs.rm(globalJsonc, { force: true }).catch(() => {})
+    } else {
+      await Bun.write(globalJsonc, backupJsonc)
+    }
+  }
+})
+
 describe("tui.plugin.loader", () => {
   let data: Data
 

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

@@ -44,12 +44,13 @@ test("toggles plugin runtime state by exported id", async () => {
     plugin_enabled: {
       "demo.toggle": false,
     },
-    plugin_meta: {
-      [tmp.extra.spec]: {
+    plugin_records: [
+      {
+        item: [tmp.extra.spec, { marker: tmp.extra.marker }],
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },
-    },
+    ],
   })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -121,12 +122,13 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
     plugin_enabled: {
       "demo.startup": false,
     },
-    plugin_meta: {
-      [tmp.extra.spec]: {
+    plugin_records: [
+      {
+        item: [tmp.extra.spec, { marker: tmp.extra.marker }],
         scope: "local",
         source: path.join(tmp.path, "tui.json"),
       },
-    },
+    ],
   })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)

+ 31 - 1
packages/opencode/test/config/config.test.ts

@@ -1822,6 +1822,22 @@ describe("resolvePluginSpec", () => {
     expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
   })
 
+  test("resolves windows-style relative plugin directory specs", async () => {
+    if (process.platform !== "win32") return
+
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const plugin = path.join(dir, "plugin")
+        await fs.mkdir(plugin, { recursive: true })
+        await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
+      },
+    })
+
+    const file = path.join(tmp.path, "opencode.json")
+    const hit = await Config.resolvePluginSpec(".\\plugin", file)
+    expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
+  })
+
   test("resolves relative file plugin paths to file urls", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
@@ -1834,7 +1850,7 @@ describe("resolvePluginSpec", () => {
     expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
   })
 
-  test("resolves plugin directory paths to package main files", async () => {
+  test("resolves plugin directory paths to directory urls", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
         const plugin = path.join(dir, "plugin")
@@ -1848,6 +1864,20 @@ describe("resolvePluginSpec", () => {
       },
     })
 
+    const file = path.join(tmp.path, "opencode.json")
+    const hit = await Config.resolvePluginSpec("./plugin", file)
+    expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
+  })
+
+  test("resolves plugin directories without package.json to index.ts", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const plugin = path.join(dir, "plugin")
+        await fs.mkdir(plugin, { recursive: true })
+        await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
+      },
+    })
+
     const file = path.join(tmp.path, "opencode.json")
     const hit = await Config.resolvePluginSpec("./plugin", file)
     expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)

+ 20 - 14
packages/opencode/test/config/tui.test.ts

@@ -476,12 +476,13 @@ 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(["[email protected]"])
-      expect(config.plugin_meta).toEqual({
-        "[email protected]": {
+      expect(config.plugin_records).toEqual([
+        {
+          item: "[email protected]",
           scope: "global",
           source: path.join(managedConfigDir, "tui.json"),
         },
-      })
+      ])
     },
   })
 })
@@ -539,12 +540,13 @@ test("supports tuple plugin specs with options in tui.json", async () => {
     fn: async () => {
       const config = await TuiConfig.get()
       expect(config.plugin).toEqual([["[email protected]", { enabled: true, label: "demo" }]])
-      expect(config.plugin_meta).toEqual({
-        "[email protected]": {
+      expect(config.plugin_records).toEqual([
+        {
+          item: ["[email protected]", { enabled: true, label: "demo" }],
           scope: "local",
           source: path.join(tmp.path, "tui.json"),
         },
-      })
+      ])
     },
   })
 })
@@ -578,16 +580,18 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a
         ["[email protected]", { source: "project" }],
         ["[email protected]", { source: "project" }],
       ])
-      expect(config.plugin_meta).toEqual({
-        "[email protected]": {
+      expect(config.plugin_records).toEqual([
+        {
+          item: ["[email protected]", { source: "project" }],
           scope: "local",
           source: path.join(tmp.path, "tui.json"),
         },
-        "[email protected]": {
+        {
+          item: ["[email protected]", { source: "project" }],
           scope: "local",
           source: path.join(tmp.path, "tui.json"),
         },
-      })
+      ])
     },
   })
 })
@@ -615,16 +619,18 @@ test("tracks global and local plugin metadata in merged tui config", async () =>
     fn: async () => {
       const config = await TuiConfig.get()
       expect(config.plugin).toEqual(["[email protected]", "[email protected]"])
-      expect(config.plugin_meta).toEqual({
-        "[email protected]": {
+      expect(config.plugin_records).toEqual([
+        {
+          item: "[email protected]",
           scope: "global",
           source: path.join(Global.Path.config, "tui.json"),
         },
-        "[email protected]": {
+        {
+          item: "[email protected]",
           scope: "local",
           source: path.join(tmp.path, "tui.json"),
         },
-      })
+      ])
     },
   })
 })

+ 6 - 13
packages/opencode/test/fixture/tui-runtime.ts

@@ -6,21 +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 meta = Object.fromEntries(
-    plugin.map((item) => {
-      const spec = Array.isArray(item) ? item[0] : item
-      return [
-        spec,
-        {
-          scope: "local" as const,
-          source: path.join(dir, "tui.json"),
-        },
-      ]
-    }),
-  )
+  const plugin_records = plugin.map((item) => ({
+    item,
+    scope: "local" as const,
+    source: path.join(dir, "tui.json"),
+  }))
   const get = spyOn(TuiConfig, "get").mockResolvedValue({
     plugin,
-    plugin_meta: meta,
+    plugin_records,
   })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => dir)

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

@@ -331,6 +331,57 @@ describe("plugin.loader.shared", () => {
     }
   })
 
+  test("does not use npm package exports dot for server entry", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const mod = path.join(dir, "mods", "acme-plugin")
+        const mark = path.join(dir, "dot-server.txt")
+        await fs.mkdir(mod, { recursive: true })
+
+        await Bun.write(
+          path.join(mod, "package.json"),
+          JSON.stringify({
+            name: "acme-plugin",
+            type: "module",
+            exports: { ".": "./index.js" },
+          }),
+        )
+        await Bun.write(
+          path.join(mod, "index.js"),
+          [
+            "export default {",
+            '  id: "demo.dot.server",',
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, "called")`,
+            "    return {}",
+            "  },",
+            "}",
+            "",
+          ].join("\n"),
+        )
+
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
+
+        return { mod, mark }
+      },
+    })
+
+    const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+    try {
+      const errors = await errs(tmp.path)
+      const called = await Bun.file(tmp.extra.mark)
+        .text()
+        .then(() => true)
+        .catch(() => false)
+
+      expect(called).toBe(false)
+      expect(errors.some((x) => x.includes('exports["./server"]') && x.includes("package.json main"))).toBe(true)
+    } finally {
+      install.mockRestore()
+    }
+  })
+
   test("rejects npm server export that resolves outside plugin directory", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
@@ -576,6 +627,55 @@ describe("plugin.loader.shared", () => {
     })
   })
 
+  test("initializes server plugins in config order", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const a = path.join(dir, "a-plugin.ts")
+        const b = path.join(dir, "b-plugin.ts")
+        const marker = path.join(dir, "server-order.txt")
+        const aSpec = pathToFileURL(a).href
+        const bSpec = pathToFileURL(b).href
+
+        await Bun.write(
+          a,
+          `import fs from "fs/promises"
+
+export default {
+  id: "demo.order.a",
+  server: async () => {
+    await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
+    await Bun.sleep(25)
+    await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
+    return {}
+  },
+}
+`,
+        )
+        await Bun.write(
+          b,
+          `import fs from "fs/promises"
+
+export default {
+  id: "demo.order.b",
+  server: async () => {
+    await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
+    return {}
+  },
+}
+`,
+        )
+
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
+
+        return { marker }
+      },
+    })
+
+    await load(tmp.path)
+    const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
+    expect(lines).toEqual(["a-start", "a-end", "b"])
+  })
+
   test("skips external plugins in pure mode", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {