From 5c95616579ee4ed44e856ba33b465bdfa617f6f8 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Mon, 9 Mar 2026 14:32:50 +0100 Subject: [PATCH] parallel pre-load --- packages/opencode/src/cli/cmd/tui/plugin.ts | 106 +++++++++++--------- packages/opencode/src/plugin/index.ts | 28 ++++-- 2 files changed, 79 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/plugin.ts b/packages/opencode/src/cli/cmd/tui/plugin.ts index d68e1c0202..8b5f3df916 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin.ts @@ -193,14 +193,14 @@ export namespace TuiPlugin { await deps } - const loadOne = async (item: (typeof plugins)[number], retry = false) => { + const prep = async (item: (typeof plugins)[number], retry = false) => { const spec = Config.pluginSpecifier(item) log.info("loading tui plugin", { path: spec, retry }) const target = await resolvePluginTarget(spec).catch((error) => { log.error("failed to resolve tui plugin", { path: spec, retry, error }) return }) - if (!target) return false + if (!target) return const meta = await PluginMeta.touch(spec, target).catch((error) => { log.warn("failed to track tui plugin", { path: spec, retry, error }) }) @@ -217,62 +217,74 @@ export namespace TuiPlugin { const root = pluginRoot(spec, target) const install = makeInstallFn(getPluginMeta(config, item), root) - const mod = await import(target).catch((error) => { log.error("failed to load tui plugin", { path: spec, retry, error }) return }) - if (!mod) return false + if (!mod) return - for (const [name, entry] of uniqueModuleEntries(mod)) { - if (!entry || typeof entry !== "object") { - log.warn("ignoring non-object tui plugin export", { - path: spec, - name, - type: entry === null ? "null" : typeof entry, - }) - continue - } - - const slotPlugin = getTuiSlotPlugin(entry) - if (slotPlugin) input.slots.register(slotPlugin) - - const tuiPlugin = getTuiPlugin(entry) - if (!tuiPlugin) continue - await tuiPlugin( - { - ...input, - api: { - command: input.api.command, - route: input.api.route, - ui: input.api.ui, - keybind: input.api.keybind, - theme: Object.create(input.api.theme, { - install: { - value: install, - configurable: true, - enumerable: true, - }, - }), - }, - }, - Config.pluginOptions(item), - ) + return { + item, + spec, + mod, + install, } - - return true } try { - for (const item of plugins) { - const ok = await loadOne(item) - if (ok) continue + const loaded = await Promise.all(plugins.map((item) => prep(item))) - const spec = Config.pluginSpecifier(item) - if (!spec.startsWith("file://")) continue + for (let i = 0; i < plugins.length; i++) { + let load = loaded[i] + if (!load) { + const item = plugins[i] + if (!item) continue + const spec = Config.pluginSpecifier(item) + if (!spec.startsWith("file://")) continue + await wait() + load = await prep(item, true) + } + if (!load) continue - await wait() - await loadOne(item, true) + // Keep plugin execution sequential for deterministic side effects: + // command registration order affects keybind/command precedence, + // route registration is last-wins when ids collide, + // and hook chains rely on stable plugin ordering. + for (const [name, value] of uniqueModuleEntries(load.mod)) { + if (!value || typeof value !== "object") { + log.warn("ignoring non-object tui plugin export", { + path: load.spec, + name, + type: value === null ? "null" : typeof value, + }) + continue + } + + const slotPlugin = getTuiSlotPlugin(value) + if (slotPlugin) input.slots.register(slotPlugin) + + const tuiPlugin = getTuiPlugin(value) + if (!tuiPlugin) continue + await tuiPlugin( + { + ...input, + api: { + command: input.api.command, + route: input.api.route, + ui: input.api.ui, + keybind: input.api.keybind, + theme: Object.create(input.api.theme, { + install: { + value: load.install, + configurable: true, + enumerable: true, + }, + }), + }, + }, + Config.pluginOptions(load.item), + ) + } } } finally { await PluginMeta.persist().catch((error) => { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 184f35d86e..2207db7c06 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -82,13 +82,13 @@ export namespace Plugin { return value.server } - for (const item of plugins) { + const prep = async (item: (typeof plugins)[number]) => { const spec = Config.pluginSpecifier(item) // ignore old codex plugin since it is supported first party now - if (spec.includes("opencode-openai-codex-auth") || spec.includes("opencode-copilot-auth")) continue + if (spec.includes("opencode-openai-codex-auth") || spec.includes("opencode-copilot-auth")) return log.info("loading plugin", { path: spec }) const target = await resolvePlugin(spec) - if (!target) continue + if (!target) return const mod = await import(target).catch((err) => { const message = err instanceof Error ? err.message : String(err) log.error("failed to load plugin", { path: spec, error: message }) @@ -99,23 +99,35 @@ export namespace Plugin { }) return }) - if (!mod) continue + if (!mod) return + return { + item, + spec, + mod, + } + } + const loaded = await Promise.all(plugins.map((item) => prep(item))) + for (const load of loaded) { + if (!load) continue + + // Keep plugin execution sequential so hook registration and execution + // order remains deterministic across plugin runs. // Prevent duplicate initialization when plugins export the same function // as both a named export and default export (e.g., `export const X` and `export default X`). // uniqueModuleEntries keeps only the first export for each shared value reference. await (async () => { - for (const [, entry] of uniqueModuleEntries(mod)) { + for (const [, entry] of uniqueModuleEntries(load.mod)) { const server = getServerPlugin(entry) if (!server) throw new TypeError("Plugin export is not a function") - hooks.push(await server(input, Config.pluginOptions(item))) + hooks.push(await server(input, Config.pluginOptions(load.item))) } })().catch((err) => { const message = err instanceof Error ? err.message : String(err) - log.error("failed to load plugin", { path: spec, error: message }) + log.error("failed to load plugin", { path: load.spec, error: message }) Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ - message: `Failed to load plugin ${spec}: ${message}`, + message: `Failed to load plugin ${load.spec}: ${message}`, }).toObject(), }) })