Dax 4 месяцев назад
Родитель
Сommit
675a46e23e
84 измененных файлов с 1413 добавлено и 1009 удалено
  1. 2 0
      bun.lock
  2. 3 0
      packages/opencode/.gitignore
  3. 1 0
      packages/opencode/package.json
  4. 1 1
      packages/opencode/script/schema.ts
  5. 2 1
      packages/opencode/src/acp/agent.ts
  6. 3 2
      packages/opencode/src/cli/cmd/mcp.ts
  7. 5 2
      packages/opencode/src/cli/cmd/tui/app.tsx
  8. 2 7
      packages/opencode/src/cli/cmd/tui/attach.ts
  9. 1 1
      packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx
  10. 2 2
      packages/opencode/src/cli/cmd/tui/component/error-component.tsx
  11. 0 0
      packages/opencode/src/cli/cmd/tui/component/prompt/cwd.ts
  12. 31 22
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  13. 5 0
      packages/opencode/src/cli/cmd/tui/config/cwd.ts
  14. 5 10
      packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts
  15. 4 3
      packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
  16. 208 0
      packages/opencode/src/cli/cmd/tui/config/tui.ts
  17. 1 1
      packages/opencode/src/cli/cmd/tui/context/keybind.tsx
  18. 26 14
      packages/opencode/src/cli/cmd/tui/context/local.tsx
  19. 4 4
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  20. 1 1
      packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
  21. 6 0
      packages/opencode/src/cli/cmd/tui/layer.ts
  22. 3 3
      packages/opencode/src/cli/cmd/tui/plugin/api.tsx
  23. 48 61
      packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
  24. 2 2
      packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
  25. 5 8
      packages/opencode/src/cli/cmd/tui/thread.ts
  26. 2 3
      packages/opencode/src/cli/cmd/tui/ui/toast.tsx
  27. 19 6
      packages/opencode/src/cli/cmd/tui/util/clipboard.ts
  28. 1 1
      packages/opencode/src/cli/cmd/tui/util/scroll.ts
  29. 3 2
      packages/opencode/src/cli/cmd/upgrade.ts
  30. 20 0
      packages/opencode/src/cli/effect/runtime.ts
  31. 60 28
      packages/opencode/src/cli/error.ts
  32. 4 1
      packages/opencode/src/cli/network.ts
  33. 3 2
      packages/opencode/src/cli/upgrade.ts
  34. 2 1
      packages/opencode/src/config/config.ts
  35. 0 1
      packages/opencode/src/config/index.ts
  36. 164 0
      packages/opencode/src/config/keybinds.ts
  37. 2 2
      packages/opencode/src/config/paths.ts
  38. 75 0
      packages/opencode/src/config/plugin.ts
  39. 0 212
      packages/opencode/src/config/tui.ts
  40. 2 0
      packages/opencode/src/effect/app-runtime.ts
  41. 5 3
      packages/opencode/src/effect/observability.ts
  42. 3 2
      packages/opencode/src/file/file.ts
  43. 3 2
      packages/opencode/src/index.ts
  44. 6 8
      packages/opencode/src/installation/installation.ts
  45. 0 7
      packages/opencode/src/installation/meta.ts
  46. 8 0
      packages/opencode/src/installation/version.ts
  47. 3 2
      packages/opencode/src/mcp/mcp.ts
  48. 2 1
      packages/opencode/src/npm/npm.ts
  49. 4 3
      packages/opencode/src/plugin/codex.ts
  50. 5 4
      packages/opencode/src/plugin/github-copilot/copilot.ts
  51. 1 1
      packages/opencode/src/plugin/install.ts
  52. 13 13
      packages/opencode/src/plugin/loader.ts
  53. 1 1
      packages/opencode/src/project/bootstrap.ts
  54. 44 64
      packages/opencode/src/provider/provider.ts
  55. 2 1
      packages/opencode/src/server/instance/global.ts
  56. 2 1
      packages/opencode/src/session/llm.ts
  57. 2 1
      packages/opencode/src/session/session.ts
  58. 3 3
      packages/opencode/src/storage/db.ts
  59. 33 0
      packages/opencode/src/temporary.ts
  60. 2 2
      packages/opencode/src/util/filesystem.ts
  61. 13 9
      packages/opencode/test/cli/tui/plugin-add.test.ts
  62. 3 5
      packages/opencode/test/cli/tui/plugin-install.test.ts
  63. 8 8
      packages/opencode/test/cli/tui/plugin-lifecycle.test.ts
  64. 25 33
      packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
  65. 4 5
      packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
  66. 82 13
      packages/opencode/test/cli/tui/plugin-loader.test.ts
  67. 7 9
      packages/opencode/test/cli/tui/plugin-toggle.test.ts
  68. 1 10
      packages/opencode/test/cli/tui/thread.test.ts
  69. 11 10
      packages/opencode/test/config/config.test.ts
  70. 0 0
      packages/opencode/test/config/plugin.test.ts
  71. 158 335
      packages/opencode/test/config/tui.test.ts
  72. 2 2
      packages/opencode/test/file/index.test.ts
  73. 15 11
      packages/opencode/test/fixture/tui-runtime.ts
  74. 3 3
      packages/opencode/test/storage/db.test.ts
  75. 1 0
      packages/opencode/test/tool/read.test.ts
  76. 13 13
      packages/opencode/test/util/filesystem.test.ts
  77. 4 0
      packages/opencode/time.ts
  78. 153 0
      packages/opencode/trace-imports.ts
  79. 2 1
      packages/opencode/tsconfig.json
  80. 5 2
      packages/shared/package.json
  81. 20 5
      packages/shared/src/npm.ts
  82. 6 0
      packages/shared/src/util/error.ts
  83. 7 3
      packages/shared/src/util/flock.ts
  84. 0 9
      packages/shared/tsconfig.json

+ 2 - 0
bun.lock

@@ -523,7 +523,9 @@
         "zod": "catalog:",
       },
       "devDependencies": {
+        "@tsconfig/bun": "catalog:",
         "@types/bun": "catalog:",
+        "@types/npmcli__arborist": "6.3.3",
         "@types/semver": "catalog:",
       },
     },

+ 3 - 0
packages/opencode/.gitignore

@@ -1,6 +1,9 @@
 research
 dist
+dist-*
 gen
 app.log
 src/provider/models-snapshot.js
 src/provider/models-snapshot.d.ts
+script/build-*.ts
+temporary-*.md

+ 1 - 0
packages/opencode/package.json

@@ -14,6 +14,7 @@
     "fix-node-pty": "bun run script/fix-node-pty.ts",
     "upgrade-opentui": "bun run script/upgrade-opentui.ts",
     "dev": "bun run --conditions=browser ./src/index.ts",
+    "dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
     "db": "bun drizzle-kit"
   },
   "bin": {

+ 1 - 1
packages/opencode/script/schema.ts

@@ -2,7 +2,7 @@
 
 import { z } from "zod"
 import { Config } from "../src/config"
-import { TuiConfig } from "../src/config"
+import { TuiConfig } from "../src/cli/cmd/tui/config/tui"
 
 function generate(schema: z.ZodType) {
   const result = z.toJSONSchema(schema, {

+ 2 - 1
packages/opencode/src/acp/agent.ts

@@ -49,6 +49,7 @@ import { z } from "zod"
 import { LoadAPIKeyError } from "ai"
 import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
 import { applyPatch } from "diff"
+import { InstallationVersion } from "@/installation/version"
 
 type ModeOption = { id: string; name: string; description?: string }
 type ModelOption = { modelId: string; name: string }
@@ -570,7 +571,7 @@ export namespace ACP {
         authMethods: [authMethod],
         agentInfo: {
           name: "OpenCode",
-          version: Installation.VERSION,
+          version: InstallationVersion,
         },
       }
     }

+ 3 - 2
packages/opencode/src/cli/cmd/mcp.ts

@@ -10,6 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider"
 import { Config } from "../../config"
 import { Instance } from "../../project/instance"
 import { Installation } from "../../installation"
+import { InstallationVersion } from "../../installation/version"
 import path from "path"
 import { Global } from "../../global"
 import { modify, applyEdits } from "jsonc-parser"
@@ -697,7 +698,7 @@ export const McpDebugCommand = cmd({
               params: {
                 protocolVersion: "2024-11-05",
                 capabilities: {},
-                clientInfo: { name: "opencode-debug", version: Installation.VERSION },
+                clientInfo: { name: "opencode-debug", version: InstallationVersion },
               },
               id: 1,
             }),
@@ -746,7 +747,7 @@ export const McpDebugCommand = cmd({
             try {
               const client = new Client({
                 name: "opencode-debug",
-                version: Installation.VERSION,
+                version: InstallationVersion,
               })
               await client.connect(transport)
               prompts.log.success("Connection successful (already authenticated)")

+ 5 - 2
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -57,7 +57,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
 import open from "open"
 import { PromptRefProvider, usePromptRef } from "./context/prompt"
 import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
-import { TuiConfig } from "@/config"
+import { TuiConfig } from "@/cli/cmd/tui/config/tui"
 import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
 import { FormatError, FormatUnknownError } from "@/cli/error"
 
@@ -235,7 +235,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
     renderer,
   })
   const [ready, setReady] = createSignal(false)
-  TuiPluginRuntime.init(api)
+  TuiPluginRuntime.init({
+    api,
+    config: tuiConfig,
+  })
     .catch((error) => {
       console.error("Failed to load TUI plugins", error)
     })

+ 2 - 7
packages/opencode/src/cli/cmd/tui/attach.ts

@@ -2,9 +2,7 @@ import { cmd } from "../cmd"
 import { UI } from "@/cli/ui"
 import { tui } from "./app"
 import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
-import { TuiConfig } from "@/config"
-import { Instance } from "@/project/instance"
-import { existsSync } from "fs"
+import { TuiConfig } from "@/cli/cmd/tui/config/tui"
 
 export const AttachCommand = cmd({
   command: "attach <url>",
@@ -66,10 +64,7 @@ export const AttachCommand = cmd({
         const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
         return { Authorization: auth }
       })()
-      const config = await Instance.provide({
-        directory: directory && existsSync(directory) ? directory : process.cwd(),
-        fn: () => TuiConfig.get(),
-      })
+      const config = await TuiConfig.get()
       await tui({
         url: args.url,
         config,

+ 1 - 1
packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx

@@ -20,7 +20,7 @@ export function DialogAgent() {
   return (
     <DialogSelect
       title="Select agent"
-      current={local.agent.current().name}
+      current={local.agent.current()?.name}
       options={options()}
       onSelect={(option) => {
         local.agent.set(option.value)

+ 2 - 2
packages/opencode/src/cli/cmd/tui/component/error-component.tsx

@@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core"
 import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
 import * as Clipboard from "@tui/util/clipboard"
 import { createSignal } from "solid-js"
-import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
 import { win32FlushInputBuffer } from "../win32"
 import { getScrollAcceleration } from "../util/scroll"
 
@@ -53,7 +53,7 @@ export function ErrorComponent(props: {
     )
   }
 
-  issueURL.searchParams.set("opencode-version", Installation.VERSION)
+  issueURL.searchParams.set("opencode-version", InstallationVersion)
 
   const copyIssueURL = () => {
     void Clipboard.copy(issueURL.toString()).then(() => {

+ 0 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/cwd.ts


+ 31 - 22
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -602,6 +602,8 @@ export function Prompt(props: PromptProps) {
     if (props.disabled) return
     if (autocomplete?.visible) return
     if (!store.prompt.input) return
+    const agent = local.agent.current()
+    if (!agent) return
     const trimmed = store.prompt.input.trim()
     if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
       void exit()
@@ -662,7 +664,7 @@ export function Prompt(props: PromptProps) {
     if (store.mode === "shell") {
       void sdk.client.session.shell({
         sessionID,
-        agent: local.agent.current().name,
+        agent: agent.name,
         model: {
           providerID: selectedModel.providerID,
           modelID: selectedModel.modelID,
@@ -689,7 +691,7 @@ export function Prompt(props: PromptProps) {
         sessionID,
         command: command.slice(1),
         arguments: args,
-        agent: local.agent.current().name,
+        agent: agent.name,
         model: `${selectedModel.providerID}/${selectedModel.modelID}`,
         messageID,
         variant,
@@ -706,7 +708,7 @@ export function Prompt(props: PromptProps) {
           sessionID,
           ...selectedModel,
           messageID,
-          agent: local.agent.current().name,
+          agent: agent.name,
           model: selectedModel,
           variant,
           parts: [
@@ -829,7 +831,9 @@ export function Prompt(props: PromptProps) {
   const highlight = createMemo(() => {
     if (keybind.leader) return theme.border
     if (store.mode === "shell") return theme.primary
-    return local.agent.color(local.agent.current().name)
+    const agent = local.agent.current()
+    if (!agent) return theme.border
+    return local.agent.color(agent.name)
   })
 
   const showVariant = createMemo(() => {
@@ -851,7 +855,8 @@ export function Prompt(props: PromptProps) {
   })
 
   const spinnerDef = createMemo(() => {
-    const color = local.agent.color(local.agent.current().name)
+    const agent = local.agent.current()
+    const color = agent ? local.agent.color(agent.name) : theme.border
     return {
       frames: createFrames({
         color,
@@ -1041,7 +1046,7 @@ export function Prompt(props: PromptProps) {
                 const isUrl = /^(https?):\/\//.test(filepath)
                 if (!isUrl) {
                   try {
-                    const mime = Filesystem.mimeType(filepath)
+                    const mime = await Filesystem.mimeType(filepath)
                     const filename = path.basename(filepath)
                     // Handle SVG as raw text content, not as base64 image
                     if (mime === "image/svg+xml") {
@@ -1107,22 +1112,26 @@ export function Prompt(props: PromptProps) {
             />
             <box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
               <box flexDirection="row" gap={1}>
-                <text fg={highlight()}>
-                  {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
-                </text>
-                <Show when={store.mode === "normal"}>
-                  <box flexDirection="row" gap={1}>
-                    <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
-                      {local.model.parsed().model}
-                    </text>
-                    <text fg={theme.textMuted}>{currentProviderLabel()}</text>
-                    <Show when={showVariant()}>
-                      <text fg={theme.textMuted}>·</text>
-                      <text>
-                        <span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
-                      </text>
-                    </Show>
-                  </box>
+                <Show when={local.agent.current()} fallback={<box height={1} />}>
+                  {(agent) => (
+                    <>
+                      <text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text>
+                      <Show when={store.mode === "normal"}>
+                        <box flexDirection="row" gap={1}>
+                          <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
+                            {local.model.parsed().model}
+                          </text>
+                          <text fg={theme.textMuted}>{currentProviderLabel()}</text>
+                          <Show when={showVariant()}>
+                            <text fg={theme.textMuted}>·</text>
+                            <text>
+                              <span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
+                            </text>
+                          </Show>
+                        </box>
+                      </Show>
+                    </>
+                  )}
                 </Show>
               </box>
               <Show when={hasRightContent()}>

+ 5 - 0
packages/opencode/src/cli/cmd/tui/config/cwd.ts

@@ -0,0 +1,5 @@
+import { Context } from "effect"
+
+export const CurrentWorkingDirectory = Context.Reference<string>("CurrentWorkingDirectory", {
+  defaultValue: () => process.cwd(),
+})

+ 5 - 10
packages/opencode/src/config/tui-migrate.ts → packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts

@@ -2,13 +2,11 @@ import path from "path"
 import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
 import { unique } from "remeda"
 import z from "zod"
-import * as ConfigPaths from "./paths"
 import { TuiInfo, TuiOptions } from "./tui-schema"
-import { Instance } from "@/project/instance"
 import { Flag } from "@/flag/flag"
-import { Log } from "@/util"
-import { Filesystem } from "@/util"
 import { Global } from "@/global"
+import { Filesystem, Log } from "@/util"
+import * as ConfigPaths from "@/config/paths"
 
 const log = Log.create({ service: "tui.migrate" })
 
@@ -26,9 +24,9 @@ const TuiLegacy = z
   .strip()
 
 interface MigrateInput {
+  cwd: string
   directories: string[]
   custom?: string
-  managed: string
 }
 
 /**
@@ -134,16 +132,13 @@ async function backupAndStripLegacy(file: string, source: string) {
     })
 }
 
-async function opencodeFiles(input: { directories: string[]; managed: string }) {
-  const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
-    ? []
-    : await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
+async function opencodeFiles(input: { directories: string[]; cwd: string }) {
+  const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
   const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
   for (const dir of unique(input.directories)) {
     files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
   }
   if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
-  files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
 
   const existing = await Promise.all(
     unique(files).map(async (file) => {

+ 4 - 3
packages/opencode/src/config/tui-schema.ts → packages/opencode/src/cli/cmd/tui/config/tui-schema.ts

@@ -1,9 +1,10 @@
 import z from "zod"
-import * as Config from "./config"
+import { ConfigPlugin } from "@/config/plugin"
+import { ConfigKeybinds } from "@/config/keybinds"
 
 const KeybindOverride = z
   .object(
-    Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
+    Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
       string,
       z.ZodOptional<z.ZodString>
     >,
@@ -30,7 +31,7 @@ export const TuiInfo = z
     $schema: z.string().optional(),
     theme: z.string().optional(),
     keybinds: KeybindOverride.optional(),
-    plugin: Config.PluginSpec.array().optional(),
+    plugin: ConfigPlugin.Spec.array().optional(),
     plugin_enabled: z.record(z.string(), z.boolean()).optional(),
   })
   .extend(TuiOptions.shape)

+ 208 - 0
packages/opencode/src/cli/cmd/tui/config/tui.ts

@@ -0,0 +1,208 @@
+import z from "zod"
+import { mergeDeep, unique } from "remeda"
+import { Context, Effect, Fiber, Layer } from "effect"
+import * as ConfigPaths from "@/config/paths"
+import { migrateTuiConfig } from "./tui-migrate"
+import { TuiInfo } from "./tui-schema"
+import { Flag } from "@/flag/flag"
+import { isRecord } from "@/util/record"
+import { Global } from "@/global"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Npm } from "@opencode-ai/shared/npm"
+import { CurrentWorkingDirectory } from "./cwd"
+import { ConfigPlugin } from "@/config/plugin"
+import { ConfigKeybinds } from "@/config/keybinds"
+import { InstallationLocal, InstallationVersion } from "@/installation/version"
+import { makeRuntime } from "@/cli/effect/runtime"
+import { Filesystem, Log } from "@/util"
+
+export namespace TuiConfig {
+  const log = Log.create({ service: "tui.config" })
+
+  export const Info = TuiInfo
+
+  type Acc = {
+    result: Info
+  }
+
+  type State = {
+    config: Info
+    deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
+  }
+
+  export type Info = z.output<typeof Info> & {
+    // Internal resolved plugin list used by runtime loading.
+    plugin_origins?: ConfigPlugin.Origin[]
+  }
+
+  export interface Interface {
+    readonly get: () => Effect.Effect<Info>
+    readonly waitForDependencies: () => Effect.Effect<void>
+  }
+
+  export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
+
+  function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
+    if (Filesystem.contains(ctx.directory, file)) return "local"
+    // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
+    return "global"
+  }
+
+  function customPath() {
+    return Flag.OPENCODE_TUI_CONFIG
+  }
+
+  function normalize(raw: Record<string, unknown>) {
+    const data = { ...raw }
+    if (!("tui" in data)) return data
+    if (!isRecord(data.tui)) {
+      delete data.tui
+      return data
+    }
+
+    const tui = data.tui
+    delete data.tui
+    return {
+      ...tui,
+      ...data,
+    }
+  }
+
+  async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
+    const data = await loadFile(file)
+    acc.result = mergeDeep(acc.result, data)
+    if (!data.plugin?.length) return
+
+    const scope = pluginScope(file, ctx)
+    const plugins = ConfigPlugin.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
+  }
+
+  async function loadState(ctx: { directory: string }) {
+    let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
+    const directories = await ConfigPaths.directories(ctx.directory)
+    const custom = customPath()
+    await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
+    // Re-compute after migration since migrateTuiConfig may have created new tui.json files
+    projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
+
+    const acc: Acc = {
+      result: {},
+    }
+
+    for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
+      await mergeFile(acc, file, ctx)
+    }
+
+    if (custom) {
+      await mergeFile(acc, custom, ctx)
+      log.debug("loaded custom tui config", { path: custom })
+    }
+
+    for (const file of projectFiles) {
+      await mergeFile(acc, file, ctx)
+    }
+
+    const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
+
+    for (const dir of dirs) {
+      if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
+      for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
+        await mergeFile(acc, file, ctx)
+      }
+    }
+
+    const keybinds = { ...(acc.result.keybinds ?? {}) }
+    if (process.platform === "win32") {
+      // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
+      keybinds.terminal_suspend = "none"
+      keybinds.input_undo ??= unique([
+        "ctrl+z",
+        ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
+      ]).join(",")
+    }
+    acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
+
+    return {
+      config: acc.result,
+      dirs: acc.result.plugin?.length ? dirs : [],
+    }
+  }
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const directory = yield* CurrentWorkingDirectory
+      const npm = yield* Npm.Service
+      const data = yield* Effect.promise(() => loadState({ directory }))
+      const deps = yield* Effect.forEach(
+        data.dirs,
+        (dir) =>
+          npm
+            .install(dir, {
+              add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
+            })
+            .pipe(Effect.forkScoped),
+        {
+          concurrency: "unbounded",
+        },
+      )
+
+      const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
+
+      const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
+        Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
+      )
+      return Service.of({ get, waitForDependencies })
+    }).pipe(Effect.withSpan("TuiConfig.layer")),
+  )
+
+  export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
+
+  export async function waitForDependencies() {
+    await runPromise((svc) => svc.waitForDependencies())
+  }
+
+  export async function get() {
+    return runPromise((svc) => svc.get())
+  }
+
+  async function loadFile(filepath: string): Promise<Info> {
+    const text = await ConfigPaths.readFile(filepath)
+    if (!text) return {}
+    return load(text, filepath).catch((error) => {
+      log.warn("failed to load tui config", { path: filepath, error })
+      return {}
+    })
+  }
+
+  async function load(text: string, configFilepath: string): Promise<Info> {
+    const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
+    if (!isRecord(raw)) return {}
+
+    // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
+    // (mirroring the old opencode.json shape) still get their settings applied.
+    const normalized = normalize(raw)
+
+    const parsed = Info.safeParse(normalized)
+    if (!parsed.success) {
+      log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
+      return {}
+    }
+
+    const data = parsed.data
+    if (data.plugin) {
+      for (let i = 0; i < data.plugin.length; i++) {
+        data.plugin[i] = await ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath)
+      }
+    }
+
+    return data
+  }
+}

+ 1 - 1
packages/opencode/src/cli/cmd/tui/context/keybind.tsx

@@ -1,7 +1,7 @@
 import { createMemo } from "solid-js"
 import { Keybind } from "@/util"
 import { pipe, mapValues } from "remeda"
-import type { TuiConfig } from "@/config"
+import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
 import type { ParsedKey, Renderable } from "@opentui/core"
 import { createStore } from "solid-js/store"
 import { useKeyboard, useRenderer } from "@opentui/solid"

+ 26 - 14
packages/opencode/src/cli/cmd/tui/context/local.tsx

@@ -1,4 +1,5 @@
 import { createStore } from "solid-js/store"
+import { createSimpleContext } from "./helper"
 import { batch, createEffect, createMemo } from "solid-js"
 import { useSync } from "@tui/context/sync"
 import { useTheme } from "@tui/context/theme"
@@ -6,14 +7,20 @@ import { uniqueBy } from "remeda"
 import path from "path"
 import { Global } from "@/global"
 import { iife } from "@/util/iife"
-import { createSimpleContext } from "./helper"
 import { useToast } from "../ui/toast"
-import { Provider } from "@/provider"
 import { useArgs } from "./args"
 import { useSDK } from "./sdk"
 import { RGBA } from "@opentui/core"
 import { Filesystem } from "@/util"
 
+export function parseModel(model: string) {
+  const [providerID, ...rest] = model.split("/")
+  return {
+    providerID: providerID,
+    modelID: rest.join("/"),
+  }
+}
+
 export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
   name: "Local",
   init: () => {
@@ -37,10 +44,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const agent = iife(() => {
       const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
       const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
-      const [agentStore, setAgentStore] = createStore<{
-        current: string
-      }>({
-        current: agents()[0].name,
+      const [agentStore, setAgentStore] = createStore({
+        current: undefined as string | undefined,
       })
       const { theme } = useTheme()
       const colors = createMemo(() => [
@@ -57,7 +62,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           return agents()
         },
         current() {
-          return agents().find((x) => x.name === agentStore.current) ?? agents()[0]
+          return agents().find((x) => x.name === agentStore.current) ?? agents().at(0)
         },
         set(name: string) {
           if (!agents().some((x) => x.name === name))
@@ -153,7 +158,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       const args = useArgs()
       const fallbackModel = createMemo(() => {
         if (args.model) {
-          const { providerID, modelID } = Provider.parseModel(args.model)
+          const { providerID, modelID } = parseModel(args.model)
           if (isModelValid({ providerID, modelID })) {
             return {
               providerID,
@@ -163,7 +168,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         }
 
         if (sync.data.config.model) {
-          const { providerID, modelID } = Provider.parseModel(sync.data.config.model)
+          const { providerID, modelID } = parseModel(sync.data.config.model)
           if (isModelValid({ providerID, modelID })) {
             return {
               providerID,
@@ -194,8 +199,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         const a = agent.current()
         return (
           getFirstValidModel(
-            () => modelStore.model[a.name],
-            () => a.model,
+            () => a && modelStore.model[a.name],
+            () => a && a.model,
             fallbackModel,
           ) ?? undefined
         )
@@ -240,7 +245,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           if (next >= recent.length) next = 0
           const val = recent[next]
           if (!val) return
-          setModelStore("model", agent.current().name, { ...val })
+          const a = agent.current()
+          if (!a) return
+          setModelStore("model", a.name, { ...val })
         },
         cycleFavorite(direction: 1 | -1) {
           const favorites = modelStore.favorite.filter((item) => isModelValid(item))
@@ -266,7 +273,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           }
           const next = favorites[index]
           if (!next) return
-          setModelStore("model", agent.current().name, { ...next })
+          const a = agent.current()
+          if (!a) return
+          setModelStore("model", a.name, { ...next })
           const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
           if (uniq.length > 10) uniq.pop()
           setModelStore(
@@ -285,7 +294,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
               })
               return
             }
-            setModelStore("model", agent.current().name, model)
+            const a = agent.current()
+            if (!a) return
+            setModelStore("model", a.name, model)
             if (options?.recent) {
               const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
               if (uniq.length > 10) uniq.pop()
@@ -387,6 +398,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     // Automatically update model when agent changes
     createEffect(() => {
       const value = agent.current()
+      if (!value) return
       if (value.model) {
         if (isModelValid(value.model))
           model.set({

+ 4 - 4
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -29,7 +29,7 @@ import { useExit } from "./exit"
 import { useArgs } from "./args"
 import { batch, createEffect, on } from "solid-js"
 import { Log } from "@/util"
-import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
+import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
 
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       provider: Provider[]
       provider_default: Record<string, string>
       provider_next: ProviderListResponse
-      console_state: ConsoleStateType
+      console_state: ConsoleState
       provider_auth: Record<string, ProviderAuthMethod[]>
       agent: Agent[]
       command: Command[]
@@ -363,7 +363,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
       const consoleStatePromise = sdk.client.experimental.console
         .get({ workspace }, { throwOnError: true })
-        .then((x) => ConsoleState.parse(x.data))
+        .then((x) => x.data)
         .catch(() => emptyConsoleState)
       const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
       const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
@@ -378,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       ]
 
       await Promise.all(blockingRequests)
-        .then(() => {
+        .then(async () => {
           const providersResponse = providersPromise.then((x) => x.data!)
           const providerListResponse = providerListPromise.then((x) => x.data!)
           const consoleStateResponse = consoleStatePromise

+ 1 - 1
packages/opencode/src/cli/cmd/tui/context/tui-config.tsx

@@ -1,4 +1,4 @@
-import { TuiConfig } from "@/config"
+import { TuiConfig } from "@/cli/cmd/tui/config/tui"
 import { createSimpleContext } from "./helper"
 
 export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({

+ 6 - 0
packages/opencode/src/cli/cmd/tui/layer.ts

@@ -0,0 +1,6 @@
+import { Layer } from "effect"
+import { TuiConfig } from "./config/tui"
+import { Npm } from "@opencode-ai/shared/npm"
+import { Observability } from "@/effect/observability"
+
+export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))

+ 3 - 3
packages/opencode/src/cli/cmd/tui/plugin/api.tsx

@@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk"
 import type { useSync } from "@tui/context/sync"
 import type { useTheme } from "@tui/context/theme"
 import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
-import type { TuiConfig } from "@/config"
+import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
 import { createPluginKeybind } from "../context/plugin-keybinds"
 import type { useKV } from "../context/kv"
 import { DialogAlert } from "../ui/dialog-alert"
@@ -18,7 +18,7 @@ import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dia
 import { Prompt } from "../component/prompt"
 import { Slot as HostSlot } from "./slots"
 import type { useToast } from "../ui/toast"
-import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
 
 type RouteEntry = {
   key: symbol
@@ -189,7 +189,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
 function appApi(): TuiPluginApi["app"] {
   return {
     get version() {
-      return Installation.VERSION
+      return InstallationVersion
     },
   }
 }

+ 48 - 61
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

@@ -1,4 +1,4 @@
-import "@opentui/solid/runtime-plugin-support"
+// import "@opentui/solid/runtime-plugin-support"
 import {
   type TuiDispose,
   type TuiPlugin,
@@ -12,13 +12,10 @@ import {
 } from "@opencode-ai/plugin/tui"
 import path from "path"
 import { fileURLToPath } from "url"
-
-import { Config } from "@/config"
-import { TuiConfig } from "@/config"
+import { TuiConfig } from "@/cli/cmd/tui/config/tui"
 import { Log } from "@/util"
 import { errorData, errorMessage } from "@/util/error"
 import { isRecord } from "@/util/record"
-import { Instance } from "@/project/instance"
 import {
   readPackageThemes,
   readPluginId,
@@ -39,16 +36,17 @@ import { Flag } from "@/flag/flag"
 import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
 import { setupSlots, Slot as View } from "./slots"
 import type { HostPluginApi, HostSlots } from "./slots"
+import { ConfigPlugin } from "@/config/plugin"
 
 type PluginLoad = {
-  options: Config.PluginOptions | undefined
+  options: ConfigPlugin.Options | undefined
   spec: string
   target: string
   retry: boolean
   source: PluginSource | "internal"
   id: string
   module: TuiPluginModule
-  origin: Config.PluginOrigin
+  origin: ConfigPlugin.Origin
   theme_root: string
   theme_files: string[]
 }
@@ -77,7 +75,7 @@ type RuntimeState = {
   slots: HostSlots
   plugins: PluginEntry[]
   plugins_by_id: Map<string, PluginEntry>
-  pending: Map<string, Config.PluginOrigin>
+  pending: Map<string, ConfigPlugin.Origin>
 }
 
 const log = Log.create({ service: "tui.plugin" })
@@ -147,7 +145,7 @@ function resolveRoot(root: string) {
 }
 
 function createThemeInstaller(
-  meta: Config.PluginOrigin,
+  meta: ConfigPlugin.Origin,
   root: string,
   spec: string,
   plugin: PluginEntry,
@@ -590,7 +588,7 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
   }
 }
 
-async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
+async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => Promise<void>) {
   return PluginLoader.loadExternal({
     items: list,
     kind: "tui",
@@ -745,7 +743,7 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
   return { plugins, ok }
 }
 
-function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
+function defaultPluginOrigin(state: RuntimeState, spec: string): ConfigPlugin.Origin {
   return {
     spec,
     scope: "local",
@@ -786,19 +784,12 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
   if (!spec) return false
 
   const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
-  const next = Config.pluginSpecifier(cfg.spec)
+  const next = ConfigPlugin.pluginSpecifier(cfg.spec)
   if (state.plugins.some((plugin) => plugin.load.spec === next)) {
     state.pending.delete(spec)
     return true
   }
-
-  const ready = await Instance.provide({
-    directory: state.directory,
-    fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
-  }).catch((error) => {
-    fail("failed to add tui plugin", { path: next, error })
-    return [] as PluginLoad[]
-  })
+  const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies())
   if (!ready.length) {
     return false
   }
@@ -905,7 +896,7 @@ 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 next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
+    const next = tui.opts ? ([spec, tui.opts] as ConfigPlugin.Spec) : spec
     state.pending.set(spec, {
       spec: next,
       scope: global ? "global" : "local",
@@ -926,7 +917,7 @@ export namespace TuiPluginRuntime {
   let runtime: RuntimeState | undefined
   export const Slot = View
 
-  export async function init(api: HostPluginApi) {
+  export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
     const cwd = process.cwd()
     if (loaded) {
       if (dir !== cwd) {
@@ -936,7 +927,7 @@ export namespace TuiPluginRuntime {
     }
 
     dir = cwd
-    loaded = load(api)
+    loaded = load(input)
     return loaded
   }
 
@@ -975,7 +966,8 @@ export namespace TuiPluginRuntime {
     }
   }
 
-  async function load(api: Api) {
+  async function load(input: { api: Api; config: TuiConfig.Info }) {
+    const { api, config } = input
     const cwd = process.cwd()
     const slots = setupSlots(api)
     const next: RuntimeState = {
@@ -987,45 +979,40 @@ export namespace TuiPluginRuntime {
       pending: new Map(),
     }
     runtime = next
+    try {
+      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 })
+      }
 
-    await Instance.provide({
-      directory: cwd,
-      fn: async () => {
-        const config = await TuiConfig.get()
-        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) {
-          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)
-          addPluginEntry(next, {
-            id: entry.id,
-            load: entry,
-            meta,
-            themes: {},
-            plugin: entry.module.tui,
-            enabled: true,
-          })
-        }
+      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)
+        addPluginEntry(next, {
+          id: entry.id,
+          load: entry,
+          meta,
+          themes: {},
+          plugin: entry.module.tui,
+          enabled: true,
+        })
+      }
 
-        const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
-        await addExternalPluginEntries(next, ready)
-
-        applyInitialPluginEnabledState(next, config)
-        for (const plugin of next.plugins) {
-          if (!plugin.enabled) continue
-          // 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.
-          await activatePluginEntry(next, plugin, false)
-        }
-      },
-    }).catch((error) => {
+      const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
+      await addExternalPluginEntries(next, ready)
+
+      applyInitialPluginEnabledState(next, config)
+      for (const plugin of next.plugins) {
+        if (!plugin.enabled) continue
+        // 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.
+        await activatePluginEntry(next, plugin, false)
+      }
+    } catch (error) {
       fail("failed to load tui plugins", { directory: cwd, error })
-    })
+    }
   }
 }

+ 2 - 2
packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx

@@ -2,7 +2,7 @@ import { useSync } from "@tui/context/sync"
 import { createMemo, Show } from "solid-js"
 import { useTheme } from "../../context/theme"
 import { useTuiConfig } from "../../context/tui-config"
-import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
 import { TuiPluginRuntime } from "../../plugin"
 
 import { getScrollAcceleration } from "../../util/scroll"
@@ -64,7 +64,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
               <span style={{ fg: theme.text }}>
                 <b>Code</b>
               </span>{" "}
-              <span>{Installation.VERSION}</span>
+              <span>{InstallationVersion}</span>
             </text>
           </TuiPluginRuntime.Slot>
         </box>

+ 5 - 8
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -8,14 +8,13 @@ import { UI } from "@/cli/ui"
 import { Log } from "@/util"
 import { errorMessage } from "@/util/error"
 import { withTimeout } from "@/util/timeout"
-import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
+import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
 import { Filesystem } from "@/util"
 import type { GlobalEvent } from "@opencode-ai/sdk/v2"
 import type { EventSource } from "./context/sdk"
 import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
-import { TuiConfig } from "@/config"
-import { Instance } from "@/project/instance"
 import { writeHeapSnapshot } from "v8"
+import { TuiConfig } from "./config/tui"
 
 declare global {
   const OPENCODE_WORKER_PATH: string
@@ -177,12 +176,9 @@ export const TuiThreadCommand = cmd({
       }
 
       const prompt = await input(args.prompt)
-      const config = await Instance.provide({
-        directory: cwd,
-        fn: () => TuiConfig.get(),
-      })
+      const config = await TuiConfig.get()
 
-      const network = await resolveNetworkOptions(args)
+      const network = resolveNetworkOptionsNoConfig(args)
       const external =
         process.argv.includes("--port") ||
         process.argv.includes("--hostname") ||
@@ -237,3 +233,4 @@ export const TuiThreadCommand = cmd({
     process.exit(0)
   },
 })
+// scratch

+ 2 - 3
packages/opencode/src/cli/cmd/tui/ui/toast.tsx

@@ -5,7 +5,7 @@ import { useTerminalDimensions } from "@opentui/solid"
 import { SplitBorder } from "../component/border"
 import { TextAttributes } from "@opentui/core"
 import z from "zod"
-import { TuiEvent } from "../event"
+import { type TuiEvent } from "../event"
 
 export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
 
@@ -56,8 +56,7 @@ function init() {
 
   const toast = {
     show(options: ToastOptions) {
-      const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
-      const { duration, ...currentToast } = parsedOptions
+      const { duration, ...currentToast } = options
       setStore("currentToast", currentToast)
       if (timeoutHandle) clearTimeout(timeoutHandle)
       timeoutHandle = setTimeout(() => {

+ 19 - 6
packages/opencode/src/cli/cmd/tui/util/clipboard.ts

@@ -1,12 +1,21 @@
 import { platform, release } from "os"
-import clipboardy from "clipboardy"
 import { lazy } from "../../../../util/lazy.js"
 import { tmpdir } from "os"
 import path from "path"
 import fs from "fs/promises"
-import { Filesystem } from "../../../../util"
-import { Process } from "../../../../util"
-import { which } from "../../../../util/which"
+import * as Filesystem from "../../../../util/filesystem"
+import * as Process from "../../../../util/process"
+
+// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
+const getWhich = lazy(async () => {
+  const { which } = await import("../../../../util/which")
+  return which
+})
+
+const getClipboardy = lazy(async () => {
+  const { default: clipboardy } = await import("clipboardy")
+  return clipboardy
+})
 
 /**
  * Writes text to clipboard via OSC 52 escape sequence.
@@ -94,14 +103,16 @@ export async function read(): Promise<Content | undefined> {
     }
   }
 
+  const clipboardy = await getClipboardy()
   const text = await clipboardy.read().catch(() => {})
   if (text) {
     return { data: text, mime: "text/plain" }
   }
 }
 
-const getCopyMethod = lazy(() => {
+const getCopyMethod = lazy(async () => {
   const os = platform()
+  const which = await getWhich()
 
   if (os === "darwin" && which("osascript")) {
     console.log("clipboard: using osascript")
@@ -180,11 +191,13 @@ const getCopyMethod = lazy(() => {
 
   console.log("clipboard: no native support")
   return async (text: string) => {
+    const clipboardy = await getClipboardy()
     await clipboardy.write(text).catch(() => {})
   }
 })
 
 export async function copy(text: string): Promise<void> {
   writeOsc52(text)
-  await getCopyMethod()(text)
+  const method = await getCopyMethod()
+  await method(text)
 }

+ 1 - 1
packages/opencode/src/cli/cmd/tui/util/scroll.ts

@@ -1,5 +1,5 @@
 import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
-import type { TuiConfig } from "@/config"
+import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
 
 export class CustomSpeedScroll implements ScrollAcceleration {
   constructor(private speed: number) {}

+ 3 - 2
packages/opencode/src/cli/cmd/upgrade.ts

@@ -3,6 +3,7 @@ import { UI } from "../ui"
 import * as prompts from "@clack/prompts"
 import { AppRuntime } from "@/effect/app-runtime"
 import { Installation } from "../../installation"
+import { InstallationVersion } from "../../installation/version"
 
 export const UpgradeCommand = {
   command: "upgrade [target]",
@@ -47,13 +48,13 @@ export const UpgradeCommand = {
       ? args.target.replace(/^v/, "")
       : await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
 
-    if (Installation.VERSION === target) {
+    if (InstallationVersion === target) {
       prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
       prompts.outro("Done")
       return
     }
 
-    prompts.log.info(`From ${Installation.VERSION} → ${target}`)
+    prompts.log.info(`From ${InstallationVersion} → ${target}`)
     const spinner = prompts.spinner()
     spinner.start("Upgrading...")
     const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(

+ 20 - 0
packages/opencode/src/cli/effect/runtime.ts

@@ -0,0 +1,20 @@
+import { Observability } from "@/effect/observability"
+import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
+
+export const memoMap = Layer.makeMemoMapUnsafe()
+
+export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
+  let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
+  const getRuntime = () =>
+    (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
+
+  return {
+    runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
+    runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
+      getRuntime().runPromiseExit(service.use(fn), options),
+    runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
+      getRuntime().runPromise(service.use(fn), options),
+    runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
+    runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
+  }
+}

+ 60 - 28
packages/opencode/src/cli/error.ts

@@ -1,48 +1,80 @@
-import { AccountServiceError, AccountTransportError } from "@/account"
-import { ConfigMarkdown } from "@/config"
+import { NamedError } from "@opencode-ai/shared/util/error"
 import { errorFormat } from "@/util/error"
-import { Config } from "../config"
-import { MCP } from "../mcp"
-import { Provider } from "../provider"
-import { UI } from "./ui"
+
+interface ErrorLike {
+  name?: string
+  _tag?: string
+  message?: string
+  data?: Record<string, any>
+}
+
+function isTaggedError(error: unknown, tag: string): boolean {
+  return (
+    typeof error === "object" && error !== null && "_tag" in error && (error as Record<string, unknown>)._tag === tag
+  )
+}
 
 export function FormatError(input: unknown) {
-  if (MCP.Failed.isInstance(input))
-    return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
-  if (input instanceof AccountTransportError || input instanceof AccountServiceError) {
-    return input.message
+  // MCPFailed: { name: string }
+  if (NamedError.hasName(input, "MCPFailed")) {
+    return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
   }
-  if (Provider.ModelNotFoundError.isInstance(input)) {
-    const { providerID, modelID, suggestions } = input.data
+
+  // AccountServiceError, AccountTransportError: TaggedErrorClass
+  if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) {
+    return (input as ErrorLike).message ?? ""
+  }
+
+  // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
+  if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
+    const data = (input as ErrorLike).data
+    const suggestions = data?.suggestions as string[] | undefined
     return [
-      `Model not found: ${providerID}/${modelID}`,
+      `Model not found: ${data?.providerID}/${data?.modelID}`,
       ...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
       `Try: \`opencode models\` to list available models`,
       `Or check your config (opencode.json) provider/model names`,
     ].join("\n")
   }
-  if (Provider.InitError.isInstance(input)) {
-    return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.`
+
+  // ProviderInitError: { providerID: string }
+  if (NamedError.hasName(input, "ProviderInitError")) {
+    return `Failed to initialize provider "${(input as ErrorLike).data?.providerID}". Check credentials and configuration.`
   }
-  if (Config.JsonError.isInstance(input)) {
-    return (
-      `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
-    )
+
+  // ConfigJsonError: { path: string, message?: string }
+  if (NamedError.hasName(input, "ConfigJsonError")) {
+    const data = (input as ErrorLike).data
+    return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "")
   }
-  if (Config.ConfigDirectoryTypoError.isInstance(input)) {
-    return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
+
+  // ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string }
+  if (NamedError.hasName(input, "ConfigDirectoryTypoError")) {
+    const data = (input as ErrorLike).data
+    return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.`
   }
-  if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
-    return input.data.message
+
+  // ConfigFrontmatterError: { message: string }
+  if (NamedError.hasName(input, "ConfigFrontmatterError")) {
+    return (input as ErrorLike).data?.message ?? ""
   }
-  if (Config.InvalidError.isInstance(input))
+
+  // ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> }
+  if (NamedError.hasName(input, "ConfigInvalidError")) {
+    const data = (input as ErrorLike).data
+    const path = data?.path
+    const message = data?.message
+    const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined
     return [
-      `Configuration is invalid${input.data.path && input.data.path !== "config" ? ` at ${input.data.path}` : ""}` +
-        (input.data.message ? `: ${input.data.message}` : ""),
-      ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
+      `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
+      ...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
     ].join("\n")
+  }
 
-  if (UI.CancelledError.isInstance(input)) return ""
+  // UICancelledError: void (no data)
+  if (NamedError.hasName(input, "UICancelledError")) {
+    return ""
+  }
 }
 
 export function FormatUnknownError(input: unknown): string {

+ 4 - 1
packages/opencode/src/cli/network.ts

@@ -36,9 +36,12 @@ export type NetworkOptions = InferredOptionTypes<typeof options>
 export function withNetworkOptions<T>(yargs: Argv<T>) {
   return yargs.options(options)
 }
-
 export async function resolveNetworkOptions(args: NetworkOptions) {
   const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
+  return resolveNetworkOptionsNoConfig(args, config)
+}
+
+export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) {
   const portExplicitlySet = process.argv.includes("--port")
   const hostnameExplicitlySet = process.argv.includes("--hostname")
   const mdnsExplicitlySet = process.argv.includes("--mdns")

+ 3 - 2
packages/opencode/src/cli/upgrade.ts

@@ -3,6 +3,7 @@ import { Config } from "@/config"
 import { AppRuntime } from "@/effect/app-runtime"
 import { Flag } from "@/flag/flag"
 import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
 
 export async function upgrade() {
   const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
@@ -15,10 +16,10 @@ export async function upgrade() {
     return
   }
 
-  if (Installation.VERSION === latest) return
+  if (InstallationVersion === latest) return
   if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
 
-  const kind = Installation.getReleaseType(Installation.VERSION, latest)
+  const kind = Installation.getReleaseType(InstallationVersion, latest)
 
   if (config.autoupdate === "notify" || kind !== "patch") {
     await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })

+ 2 - 1
packages/opencode/src/config/config.ts

@@ -21,6 +21,7 @@ import {
 import { Instance, type InstanceContext } from "../project/instance"
 import * as LSPServer from "../lsp/server"
 import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
 import * as ConfigMarkdown from "./markdown"
 import { existsSync } from "fs"
 import { Bus } from "@/bus"
@@ -1266,7 +1267,7 @@ export const layer: Layer.Layer<
       const pkg = path.join(dir, "package.json")
       const gitignore = path.join(dir, ".gitignore")
       const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
-      const target = Installation.isLocal() ? "*" : Installation.VERSION
+      const target = Installation.isLocal() ? "*" : InstallationVersion
       const json = yield* fs.readJson(pkg).pipe(
         Effect.catch(() => Effect.succeed({} satisfies Package)),
         Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),

+ 0 - 1
packages/opencode/src/config/index.ts

@@ -1,4 +1,3 @@
 export * as Config from "./config"
 export * as ConfigMarkdown from "./markdown"
 export * as ConfigPaths from "./paths"
-export * as TuiConfig from "./tui"

+ 164 - 0
packages/opencode/src/config/keybinds.ts

@@ -0,0 +1,164 @@
+import z from "zod"
+
+export namespace ConfigKeybinds {
+  export const Keybinds = z
+    .object({
+      leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
+      app_exit: z.string().optional().default("ctrl+c,ctrl+d,<leader>q").describe("Exit the application"),
+      editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
+      theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
+      sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
+      scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
+      username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
+      status_view: z.string().optional().default("<leader>s").describe("View status"),
+      session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
+      session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
+      session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
+      session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
+      session_fork: z.string().optional().default("none").describe("Fork session from message"),
+      session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
+      session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
+      stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
+      model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
+      model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
+      session_share: z.string().optional().default("none").describe("Share current session"),
+      session_unshare: z.string().optional().default("none").describe("Unshare current session"),
+      session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
+      session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
+      messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
+      messages_page_down: z
+        .string()
+        .optional()
+        .default("pagedown,ctrl+alt+f")
+        .describe("Scroll messages down by one page"),
+      messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
+      messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
+      messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
+      messages_half_page_down: z
+        .string()
+        .optional()
+        .default("ctrl+alt+d")
+        .describe("Scroll messages down by half page"),
+      messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"),
+      messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"),
+      messages_next: z.string().optional().default("none").describe("Navigate to next message"),
+      messages_previous: z.string().optional().default("none").describe("Navigate to previous message"),
+      messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"),
+      messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
+      messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
+      messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
+      messages_toggle_conceal: z
+        .string()
+        .optional()
+        .default("<leader>h")
+        .describe("Toggle code block concealment in messages"),
+      tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"),
+      model_list: z.string().optional().default("<leader>m").describe("List available models"),
+      model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
+      model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
+      model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
+      model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
+      command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
+      agent_list: z.string().optional().default("<leader>a").describe("List agents"),
+      agent_cycle: z.string().optional().default("tab").describe("Next agent"),
+      agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
+      variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
+      variant_list: z.string().optional().default("none").describe("List model variants"),
+      input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
+      input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
+      input_submit: z.string().optional().default("return").describe("Submit input"),
+      input_newline: z
+        .string()
+        .optional()
+        .default("shift+return,ctrl+return,alt+return,ctrl+j")
+        .describe("Insert newline in input"),
+      input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"),
+      input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"),
+      input_move_up: z.string().optional().default("up").describe("Move cursor up in input"),
+      input_move_down: z.string().optional().default("down").describe("Move cursor down in input"),
+      input_select_left: z.string().optional().default("shift+left").describe("Select left in input"),
+      input_select_right: z.string().optional().default("shift+right").describe("Select right in input"),
+      input_select_up: z.string().optional().default("shift+up").describe("Select up in input"),
+      input_select_down: z.string().optional().default("shift+down").describe("Select down in input"),
+      input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"),
+      input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"),
+      input_select_line_home: z
+        .string()
+        .optional()
+        .default("ctrl+shift+a")
+        .describe("Select to start of line in input"),
+      input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"),
+      input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"),
+      input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"),
+      input_select_visual_line_home: z
+        .string()
+        .optional()
+        .default("alt+shift+a")
+        .describe("Select to start of visual line in input"),
+      input_select_visual_line_end: z
+        .string()
+        .optional()
+        .default("alt+shift+e")
+        .describe("Select to end of visual line in input"),
+      input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"),
+      input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"),
+      input_select_buffer_home: z
+        .string()
+        .optional()
+        .default("shift+home")
+        .describe("Select to start of buffer in input"),
+      input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"),
+      input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"),
+      input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"),
+      input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
+      input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
+      input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
+      input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"),
+      input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
+      input_word_forward: z
+        .string()
+        .optional()
+        .default("alt+f,alt+right,ctrl+right")
+        .describe("Move word forward in input"),
+      input_word_backward: z
+        .string()
+        .optional()
+        .default("alt+b,alt+left,ctrl+left")
+        .describe("Move word backward in input"),
+      input_select_word_forward: z
+        .string()
+        .optional()
+        .default("alt+shift+f,alt+shift+right")
+        .describe("Select word forward in input"),
+      input_select_word_backward: z
+        .string()
+        .optional()
+        .default("alt+shift+b,alt+shift+left")
+        .describe("Select word backward in input"),
+      input_delete_word_forward: z
+        .string()
+        .optional()
+        .default("alt+d,alt+delete,ctrl+delete")
+        .describe("Delete word forward in input"),
+      input_delete_word_backward: z
+        .string()
+        .optional()
+        .default("ctrl+w,ctrl+backspace,alt+backspace")
+        .describe("Delete word backward in input"),
+      history_previous: z.string().optional().default("up").describe("Previous history item"),
+      history_next: z.string().optional().default("down").describe("Next history item"),
+      session_child_first: z.string().optional().default("<leader>down").describe("Go to first child session"),
+      session_child_cycle: z.string().optional().default("right").describe("Go to next child session"),
+      session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
+      session_parent: z.string().optional().default("up").describe("Go to parent session"),
+      terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
+      terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
+      tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
+      plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
+      display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
+    })
+    .strict()
+    .meta({
+      ref: "KeybindsConfig",
+    })
+}

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

@@ -7,11 +7,11 @@ import { Filesystem } from "@/util"
 import { Flag } from "@/flag/flag"
 import { Global } from "@/global"
 
-export async function projectFiles(name: string, directory: string, worktree: string) {
+export async function projectFiles(name: string, directory: string, worktree?: string) {
   return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
 }
 
-export async function directories(directory: string, worktree: string) {
+export async function directories(directory: string, worktree?: string) {
   return [
     Global.Path.config,
     ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG

+ 75 - 0
packages/opencode/src/config/plugin.ts

@@ -0,0 +1,75 @@
+import { Glob } from "@opencode-ai/shared/util/glob"
+import z from "zod"
+import { pathToFileURL } from "url"
+import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
+import path from "path"
+
+export namespace ConfigPlugin {
+  const Options = z.record(z.string(), z.unknown())
+  export type Options = z.infer<typeof Options>
+
+  export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
+  export type Spec = z.infer<typeof Spec>
+
+  export type Scope = "global" | "local"
+
+  export type Origin = {
+    spec: Spec
+    source: string
+    scope: Scope
+  }
+
+  export async function load(dir: string) {
+    const plugins: ConfigPlugin.Spec[] = []
+
+    for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
+      cwd: dir,
+      absolute: true,
+      dot: true,
+      symlink: true,
+    })) {
+      plugins.push(pathToFileURL(item).href)
+    }
+    return plugins
+  }
+
+  export function pluginSpecifier(plugin: ConfigPlugin.Spec): string {
+    return Array.isArray(plugin) ? plugin[0] : plugin
+  }
+
+  export function pluginOptions(plugin: Spec): Options | undefined {
+    return Array.isArray(plugin) ? plugin[1] : undefined
+  }
+
+  export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise<Spec> {
+    const spec = pluginSpecifier(plugin)
+    if (!isPathPluginSpec(spec)) 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
+  }
+
+  export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] {
+    const seen = new Set<string>()
+    const list: Origin[] = []
+
+    for (const plugin of plugins.toReversed()) {
+      const spec = pluginSpecifier(plugin.spec)
+      const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
+      if (seen.has(name)) continue
+      seen.add(name)
+      list.push(plugin)
+    }
+
+    return list.toReversed()
+  }
+}

+ 0 - 212
packages/opencode/src/config/tui.ts

@@ -1,212 +0,0 @@
-import { existsSync } from "fs"
-import z from "zod"
-import { mergeDeep, unique } from "remeda"
-import { Context, Effect, Fiber, Layer } from "effect"
-import * as Config from "./config"
-import * as ConfigPaths from "./paths"
-import { migrateTuiConfig } from "./tui-migrate"
-import { TuiInfo } from "./tui-schema"
-import { Flag } from "@/flag/flag"
-import { Log } from "@/util"
-import { isRecord } from "@/util/record"
-import { Global } from "@/global"
-import { InstanceState } from "@/effect"
-import { makeRuntime } from "@/effect/run-service"
-import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-
-const log = Log.create({ service: "tui.config" })
-
-export const Info = TuiInfo
-
-type Acc = {
-  result: Info
-}
-
-type State = {
-  config: Info
-  deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
-}
-
-export type Info = z.output<typeof Info> & {
-  // Internal resolved plugin list used by runtime loading.
-  plugin_origins?: Config.PluginOrigin[]
-}
-
-export interface Interface {
-  readonly get: () => Effect.Effect<Info>
-  readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
-
-function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
-  if (AppFileSystem.contains(ctx.directory, file)) return "local"
-  if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local"
-  return "global"
-}
-
-function customPath() {
-  return Flag.OPENCODE_TUI_CONFIG
-}
-
-function normalize(raw: Record<string, unknown>) {
-  const data = { ...raw }
-  if (!("tui" in data)) return data
-  if (!isRecord(data.tui)) {
-    delete data.tui
-    return data
-  }
-
-  const tui = data.tui
-  delete data.tui
-  return {
-    ...tui,
-    ...data,
-  }
-}
-
-async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
-  const data = await loadFile(file)
-  acc.result = mergeDeep(acc.result, data)
-  if (!data.plugin?.length) return
-
-  const scope = pluginScope(file, ctx)
-  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
-}
-
-async function loadState(ctx: { directory: string; worktree: string }) {
-  let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
-    ? []
-    : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
-  const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
-  const custom = customPath()
-  const managed = Config.managedConfigDir()
-  await migrateTuiConfig({ directories, custom, managed })
-  // Re-compute after migration since migrateTuiConfig may have created new tui.json files
-  projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
-    ? []
-    : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
-
-  const acc: Acc = {
-    result: {},
-  }
-
-  for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
-    await mergeFile(acc, file, ctx)
-  }
-
-  if (custom) {
-    await mergeFile(acc, custom, ctx)
-    log.debug("loaded custom tui config", { path: custom })
-  }
-
-  for (const file of projectFiles) {
-    await mergeFile(acc, file, ctx)
-  }
-
-  const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
-
-  for (const dir of dirs) {
-    if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
-    for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
-      await mergeFile(acc, file, ctx)
-    }
-  }
-
-  if (existsSync(managed)) {
-    for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
-      await mergeFile(acc, file, ctx)
-    }
-  }
-
-  const keybinds = { ...acc.result.keybinds }
-  if (process.platform === "win32") {
-    // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
-    keybinds.terminal_suspend = "none"
-    keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
-      ",",
-    )
-  }
-  acc.result.keybinds = Config.Keybinds.parse(keybinds)
-
-  return {
-    config: acc.result,
-    dirs: acc.result.plugin?.length ? dirs : [],
-  }
-}
-
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const cfg = yield* Config.Service
-    const state = yield* InstanceState.make<State>(
-      Effect.fn("TuiConfig.state")(function* (ctx) {
-        const data = yield* Effect.promise(() => loadState(ctx))
-        const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
-          concurrency: "unbounded",
-        })
-        return { config: data.config, deps }
-      }),
-    )
-
-    const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
-
-    const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
-      InstanceState.useEffect(state, (s) =>
-        Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
-      ),
-    )
-
-    return Service.of({ get, waitForDependencies })
-  }),
-)
-
-export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
-
-const { runPromise } = makeRuntime(Service, defaultLayer)
-
-export async function get() {
-  return runPromise((svc) => svc.get())
-}
-
-export async function waitForDependencies() {
-  await runPromise((svc) => svc.waitForDependencies())
-}
-
-async function loadFile(filepath: string): Promise<Info> {
-  const text = await ConfigPaths.readFile(filepath)
-  if (!text) return {}
-  return load(text, filepath).catch((error) => {
-    log.warn("failed to load tui config", { path: filepath, error })
-    return {}
-  })
-}
-
-async function load(text: string, configFilepath: string): Promise<Info> {
-  const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
-  if (!isRecord(raw)) return {}
-
-  // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
-  // (mirroring the old opencode.json shape) still get their settings applied.
-  const normalized = normalize(raw)
-
-  const parsed = Info.safeParse(normalized)
-  if (!parsed.success) {
-    log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
-    return {}
-  }
-
-  const data = parsed.data
-  if (data.plugin) {
-    for (let i = 0; i < data.plugin.length; i++) {
-      data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
-    }
-  }
-
-  return data
-}

+ 2 - 0
packages/opencode/src/effect/app-runtime.ts

@@ -47,8 +47,10 @@ import { Pty } from "@/pty"
 import { Installation } from "@/installation"
 import { ShareNext } from "@/share"
 import { SessionShare } from "@/share"
+import { Npm } from "@opencode-ai/shared/npm"
 
 export const AppLayer = Layer.mergeAll(
+  Npm.defaultLayer,
   AppFileSystem.defaultLayer,
   Bus.defaultLayer,
   Auth.defaultLayer,

+ 5 - 3
packages/opencode/src/effect/observability.ts

@@ -3,7 +3,7 @@ import { FetchHttpClient } from "effect/unstable/http"
 import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
 import * as EffectLogger from "./logger"
 import { Flag } from "@/flag/flag"
-import { CHANNEL, VERSION } from "@/installation/meta"
+import { InstallationChannel, InstallationVersion } from "@/installation/version"
 
 const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
 export const enabled = !!base
@@ -21,9 +21,9 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
 
 const resource = {
   serviceName: "opencode",
-  serviceVersion: VERSION,
+  serviceVersion: InstallationVersion,
   attributes: {
-    "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
+    "deployment.environment.name": InstallationChannel,
     "opencode.client": Flag.OPENCODE_CLIENT,
   },
 }
@@ -76,3 +76,5 @@ export const layer = !base
         return Layer.mergeAll(trace, logs)
       }),
     )
+
+export const Observability = { enabled, layer }

+ 3 - 2
packages/opencode/src/file/file.ts

@@ -3,7 +3,7 @@ import { InstanceState } from "@/effect"
 
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Git } from "@/git"
-import { Effect, Layer, Context } from "effect"
+import { Effect, Layer, Context, Scope } from "effect"
 import * as Stream from "effect/Stream"
 import { formatPatch, structuredPatch } from "diff"
 import fuzzysort from "fuzzysort"
@@ -345,6 +345,7 @@ export const layer = Layer.effect(
     const appFs = yield* AppFileSystem.Service
     const rg = yield* Ripgrep.Service
     const git = yield* Git.Service
+    const scope = yield* Scope.Scope
 
     const state = yield* InstanceState.make<State>(
       Effect.fn("File.state")(() =>
@@ -419,7 +420,7 @@ export const layer = Layer.effect(
     })
 
     const init = Effect.fn("File.init")(function* () {
-      yield* ensure()
+      yield* ensure().pipe(Effect.forkIn(scope))
     })
 
     const status = Effect.fn("File.status")(function* () {

+ 3 - 2
packages/opencode/src/index.ts

@@ -11,6 +11,7 @@ import { UninstallCommand } from "./cli/cmd/uninstall"
 import { ModelsCommand } from "./cli/cmd/models"
 import { UI } from "./cli/ui"
 import { Installation } from "./installation"
+import { InstallationVersion } from "./installation/version"
 import { NamedError } from "@opencode-ai/shared/util/error"
 import { FormatError } from "./cli/error"
 import { ServeCommand } from "./cli/cmd/serve"
@@ -68,7 +69,7 @@ const cli = yargs(args)
   .wrap(100)
   .help("help", "show help")
   .alias("help", "h")
-  .version("version", "show version number", Installation.VERSION)
+  .version("version", "show version number", InstallationVersion)
   .alias("version", "v")
   .option("print-logs", {
     describe: "print logs to stderr",
@@ -105,7 +106,7 @@ const cli = yargs(args)
     process.env.OPENCODE_PID = String(process.pid)
 
     Log.Default.info("opencode", {
-      version: Installation.VERSION,
+      version: InstallationVersion,
       args: process.argv.slice(2),
     })
 

+ 6 - 8
packages/opencode/src/installation/installation.ts

@@ -8,9 +8,9 @@ import z from "zod"
 import { BusEvent } from "@/bus/bus-event"
 import { Flag } from "../flag/flag"
 import { Log } from "../util"
-import { CHANNEL as channel, VERSION as version } from "./meta"
 
 import semver from "semver"
+import { InstallationChannel, InstallationVersion } from "./version"
 
 const log = Log.create({ service: "installation" })
 
@@ -54,16 +54,14 @@ export const Info = z
   })
 export type Info = z.infer<typeof Info>
 
-export const VERSION = version
-export const CHANNEL = channel
-export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
+export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
 
 export function isPreview() {
-  return CHANNEL !== "latest"
+  return InstallationChannel !== "latest"
 }
 
 export function isLocal() {
-  return CHANNEL === "local"
+  return InstallationChannel === "local"
 }
 
 export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
@@ -222,7 +220,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
           const r = (yield* text(["npm", "config", "get", "registry"])).trim()
           const reg = r || "https://registry.npmjs.org"
           const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
-          const channel = CHANNEL
+          const channel = InstallationChannel
           const response = yield* httpOk.execute(
             HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
           )
@@ -321,7 +319,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
       return Service.of({
         info: Effect.fn("Installation.info")(function* () {
           return {
-            version: VERSION,
+            version: InstallationVersion,
             latest: yield* latestImpl(),
           }
         }),

+ 0 - 7
packages/opencode/src/installation/meta.ts

@@ -1,7 +0,0 @@
-declare global {
-  const OPENCODE_VERSION: string
-  const OPENCODE_CHANNEL: string
-}
-
-export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
-export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"

+ 8 - 0
packages/opencode/src/installation/version.ts

@@ -0,0 +1,8 @@
+declare global {
+  const OPENCODE_VERSION: string
+  const OPENCODE_CHANNEL: string
+}
+
+export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
+export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
+export const InstallationLocal = InstallationVersion === "local"

+ 3 - 2
packages/opencode/src/mcp/mcp.ts

@@ -15,6 +15,7 @@ import { NamedError } from "@opencode-ai/shared/util/error"
 import z from "zod/v4"
 import { Instance } from "../project/instance"
 import { Installation } from "../installation"
+import { InstallationVersion } from "../installation/version"
 import { withTimeout } from "@/util/timeout"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { McpOAuthProvider } from "./oauth-provider"
@@ -265,7 +266,7 @@ export const layer = Layer.effect(
         (t) =>
           Effect.tryPromise({
             try: () => {
-              const client = new Client({ name: "opencode", version: Installation.VERSION })
+              const client = new Client({ name: "opencode", version: InstallationVersion })
               return withTimeout(client.connect(t), timeout).then(() => client)
             },
             catch: (e) => (e instanceof Error ? e : new Error(String(e))),
@@ -763,7 +764,7 @@ export const layer = Layer.effect(
 
       return yield* Effect.tryPromise({
         try: () => {
-          const client = new Client({ name: "opencode", version: Installation.VERSION })
+          const client = new Client({ name: "opencode", version: InstallationVersion })
           return client
             .connect(transport)
             .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)

+ 2 - 1
packages/opencode/src/npm/npm.ts

@@ -7,7 +7,6 @@ import path from "path"
 import { readdir, rm } from "fs/promises"
 import { Filesystem } from "@/util"
 import { Flock } from "@opencode-ai/shared/util/flock"
-import { Arborist } from "@npmcli/arborist"
 
 const log = Log.create({ service: "npm" })
 const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
@@ -61,6 +60,7 @@ export async function outdated(pkg: string, cachedVersion: string): Promise<bool
 }
 
 export async function add(pkg: string) {
+  const { Arborist } = await import("@npmcli/arborist")
   const dir = directory(pkg)
   await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
   log.info("installing package", {
@@ -107,6 +107,7 @@ export async function install(dir: string) {
   log.info("checking dependencies", { dir })
 
   const reify = async () => {
+    const { Arborist } = await import("@npmcli/arborist")
     const arb = new Arborist({
       path: dir,
       binLinks: true,

+ 4 - 3
packages/opencode/src/plugin/codex.ts

@@ -1,6 +1,7 @@
 import type { Hooks, PluginInput } from "@opencode-ai/plugin"
 import { Log } from "../util"
 import { Installation } from "../installation"
+import { InstallationVersion } from "../installation/version"
 import { OAUTH_DUMMY_KEY } from "../auth"
 import os from "os"
 import { setTimeout as sleep } from "node:timers/promises"
@@ -510,7 +511,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
               method: "POST",
               headers: {
                 "Content-Type": "application/json",
-                "User-Agent": `opencode/${Installation.VERSION}`,
+                "User-Agent": `opencode/${InstallationVersion}`,
               },
               body: JSON.stringify({ client_id: CLIENT_ID }),
             })
@@ -534,7 +535,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
                     method: "POST",
                     headers: {
                       "Content-Type": "application/json",
-                      "User-Agent": `opencode/${Installation.VERSION}`,
+                      "User-Agent": `opencode/${InstallationVersion}`,
                     },
                     body: JSON.stringify({
                       device_auth_id: deviceData.device_auth_id,
@@ -594,7 +595,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
     "chat.headers": async (input, output) => {
       if (input.model.providerID !== "openai") return
       output.headers.originator = "opencode"
-      output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`
+      output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
       output.headers.session_id = input.sessionID
     },
     "chat.params": async (input, output) => {

+ 5 - 4
packages/opencode/src/plugin/github-copilot/copilot.ts

@@ -1,6 +1,7 @@
 import type { Hooks, PluginInput } from "@opencode-ai/plugin"
 import type { Model } from "@opencode-ai/sdk/v2"
 import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
 import { iife } from "@/util/iife"
 import { Log } from "../../util"
 import { setTimeout as sleep } from "node:timers/promises"
@@ -70,7 +71,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
           base(auth.enterpriseUrl),
           {
             Authorization: `Bearer ${auth.refresh}`,
-            "User-Agent": `opencode/${Installation.VERSION}`,
+            "User-Agent": `opencode/${InstallationVersion}`,
           },
           provider.models,
         ).catch((error) => {
@@ -150,7 +151,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
             const headers: Record<string, string> = {
               "x-initiator": isAgent ? "agent" : "user",
               ...(init?.headers as Record<string, string>),
-              "User-Agent": `opencode/${Installation.VERSION}`,
+              "User-Agent": `opencode/${InstallationVersion}`,
               Authorization: `Bearer ${info.refresh}`,
               "Openai-Intent": "conversation-edits",
             }
@@ -226,7 +227,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
               headers: {
                 Accept: "application/json",
                 "Content-Type": "application/json",
-                "User-Agent": `opencode/${Installation.VERSION}`,
+                "User-Agent": `opencode/${InstallationVersion}`,
               },
               body: JSON.stringify({
                 client_id: CLIENT_ID,
@@ -256,7 +257,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
                     headers: {
                       Accept: "application/json",
                       "Content-Type": "application/json",
-                      "User-Agent": `opencode/${Installation.VERSION}`,
+                      "User-Agent": `opencode/${InstallationVersion}`,
                     },
                     body: JSON.stringify({
                       client_id: CLIENT_ID,

+ 1 - 1
packages/opencode/src/plugin/install.ts

@@ -7,7 +7,7 @@ import {
   printParseErrorCode,
 } from "jsonc-parser"
 
-import { ConfigPaths } from "@/config"
+import * as ConfigPaths from "@/config/paths"
 import { Global } from "@/global"
 import { Filesystem } from "@/util"
 import { Flock } from "@opencode-ai/shared/util/flock"

+ 13 - 13
packages/opencode/src/plugin/loader.ts

@@ -1,5 +1,3 @@
-import { Config } from "@/config"
-import { Installation } from "@/installation"
 import {
   checkPluginCompatibility,
   createPluginEntry,
@@ -10,11 +8,13 @@ import {
   type PluginPackage,
   type PluginSource,
 } from "./shared"
+import { ConfigPlugin } from "@/config/plugin"
+import { InstallationVersion } from "@/installation/version"
 
 export namespace PluginLoader {
   export type Plan = {
     spec: string
-    options: Config.PluginOptions | undefined
+    options: ConfigPlugin.Options | undefined
     deprecated: boolean
   }
   export type Resolved = Plan & {
@@ -33,7 +33,7 @@ export namespace PluginLoader {
     mod: Record<string, unknown>
   }
 
-  type Candidate = { origin: Config.PluginOrigin; plan: Plan }
+  type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
   type Report = {
     start?: (candidate: Candidate, retry: boolean) => void
     missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
@@ -46,9 +46,9 @@ export namespace PluginLoader {
     ) => void
   }
 
-  function plan(item: Config.PluginSpec): Plan {
-    const spec = Config.pluginSpecifier(item)
-    return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
+  function plan(item: ConfigPlugin.Spec): Plan {
+    const spec = ConfigPlugin.pluginSpecifier(item)
+    return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
   }
 
   export async function resolve(
@@ -88,7 +88,7 @@ export namespace PluginLoader {
 
     if (base.source === "npm") {
       try {
-        await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
+        await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
       } catch (error) {
         return { ok: false, stage: "compatibility", error }
       }
@@ -111,8 +111,8 @@ export namespace PluginLoader {
     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,
+    finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
+    missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
     report: Report | undefined,
   ): Promise<R | undefined> {
     const plan = candidate.plan
@@ -141,11 +141,11 @@ export namespace PluginLoader {
   }
 
   type Input<R> = {
-    items: Config.PluginOrigin[]
+    items: ConfigPlugin.Origin[]
     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>
+    finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
+    missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
     report?: Report
   }
 

+ 1 - 1
packages/opencode/src/project/bootstrap.ts

@@ -26,7 +26,7 @@ export const InstanceBootstrap = Effect.gen(function* () {
       Vcs.Service,
       Snapshot.Service,
     ].map((s) => Effect.forkDetach(s.use((i) => i.init()))),
-  )
+  ).pipe(Effect.withSpan("InstanceBootstrap.init"))
 
   yield* Bus.Service.use((svc) =>
     svc.subscribeCallback(Command.Event.Executed, async (payload) => {

+ 44 - 64
packages/opencode/src/provider/provider.ts

@@ -14,6 +14,7 @@ import * as ModelsDev from "./models"
 import { Auth } from "../auth"
 import { Env } from "../env"
 import { Instance } from "../project/instance"
+import { InstallationVersion } from "../installation/version"
 import { Flag } from "../flag/flag"
 import { iife } from "@/util/iife"
 import { Global } from "../global"
@@ -24,39 +25,7 @@ import { InstanceState } from "@/effect"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { isRecord } from "@/util/record"
 
-// Direct imports for bundled providers
-import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
-import { createAnthropic } from "@ai-sdk/anthropic"
-import { createAzure } from "@ai-sdk/azure"
-import { createGoogleGenerativeAI } from "@ai-sdk/google"
-import { createVertex } from "@ai-sdk/google-vertex"
-import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
-import { createOpenAI } from "@ai-sdk/openai"
-import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
-import { createOpenRouter } from "@openrouter/ai-sdk-provider"
-import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot"
-import { createXai } from "@ai-sdk/xai"
-import { createMistral } from "@ai-sdk/mistral"
-import { createGroq } from "@ai-sdk/groq"
-import { createDeepInfra } from "@ai-sdk/deepinfra"
-import { createCerebras } from "@ai-sdk/cerebras"
-import { createCohere } from "@ai-sdk/cohere"
-import { createGateway } from "@ai-sdk/gateway"
-import { createTogetherAI } from "@ai-sdk/togetherai"
-import { createPerplexity } from "@ai-sdk/perplexity"
-import { createVercel } from "@ai-sdk/vercel"
-import { createVenice } from "venice-ai-sdk-provider"
-import { createAlibaba } from "@ai-sdk/alibaba"
-import {
-  createGitLab,
-  VERSION as GITLAB_PROVIDER_VERSION,
-  isWorkflowModel,
-  discoverWorkflowModels,
-} from "gitlab-ai-provider"
-import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
-import { GoogleAuth } from "google-auth-library"
 import * as ProviderTransform from "./transform"
-import { Installation } from "../installation"
 import { ModelID, ProviderID } from "./schema"
 
 const log = Log.create({ service: "provider" })
@@ -119,30 +88,31 @@ type BundledSDK = {
   languageModel(modelId: string): LanguageModelV3
 }
 
-const BUNDLED_PROVIDERS: Record<string, (options: any) => BundledSDK> = {
-  "@ai-sdk/amazon-bedrock": createAmazonBedrock,
-  "@ai-sdk/anthropic": createAnthropic,
-  "@ai-sdk/azure": createAzure,
-  "@ai-sdk/google": createGoogleGenerativeAI,
-  "@ai-sdk/google-vertex": createVertex,
-  "@ai-sdk/google-vertex/anthropic": createVertexAnthropic,
-  "@ai-sdk/openai": createOpenAI,
-  "@ai-sdk/openai-compatible": createOpenAICompatible,
-  "@openrouter/ai-sdk-provider": createOpenRouter,
-  "@ai-sdk/xai": createXai,
-  "@ai-sdk/mistral": createMistral,
-  "@ai-sdk/groq": createGroq,
-  "@ai-sdk/deepinfra": createDeepInfra,
-  "@ai-sdk/cerebras": createCerebras,
-  "@ai-sdk/cohere": createCohere,
-  "@ai-sdk/gateway": createGateway,
-  "@ai-sdk/togetherai": createTogetherAI,
-  "@ai-sdk/perplexity": createPerplexity,
-  "@ai-sdk/vercel": createVercel,
-  "@ai-sdk/alibaba": createAlibaba,
-  "gitlab-ai-provider": createGitLab,
-  "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
-  "venice-ai-sdk-provider": createVenice,
+const BUNDLED_PROVIDERS: Record<string, () => Promise<(opts: any) => BundledSDK>> = {
+  "@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then((m) => m.createAmazonBedrock),
+  "@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic),
+  "@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure),
+  "@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI),
+  "@ai-sdk/google-vertex": () => import("@ai-sdk/google-vertex").then((m) => m.createVertex),
+  "@ai-sdk/google-vertex/anthropic": () =>
+    import("@ai-sdk/google-vertex/anthropic").then((m) => m.createVertexAnthropic),
+  "@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI),
+  "@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible),
+  "@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter),
+  "@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai),
+  "@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral),
+  "@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq),
+  "@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra),
+  "@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras),
+  "@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere),
+  "@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway),
+  "@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI),
+  "@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity),
+  "@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel),
+  "@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba),
+  "gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab),
+  "@ai-sdk/github-copilot": () => import("./sdk/copilot").then((m) => m.createOpenaiCompatible),
+  "venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice),
 }
 
 type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
@@ -307,7 +277,9 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
       if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds)
         return { autoload: false }
 
-      const providerOptions: AmazonBedrockProviderSettings = {
+      const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
+
+      const providerOptions: Record<string, any> = {
         region: defaultRegion,
       }
 
@@ -465,6 +437,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
           project,
           location,
           fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
+            const { GoogleAuth } = await import("google-auth-library")
             const auth = new GoogleAuth()
             const client = await auth.getApplicationDefault()
             const token = await client.credential.getAccessToken()
@@ -534,6 +507,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
         },
       }),
     gitlab: Effect.fnUntraced(function* (input: Info) {
+      const {
+        VERSION: GITLAB_PROVIDER_VERSION,
+        isWorkflowModel,
+        discoverWorkflowModels,
+      } = yield* Effect.promise(() => import("gitlab-ai-provider"))
+
       const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
 
       const auth = yield* dep.auth(input.id)
@@ -547,7 +526,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
       const providerConfig = (yield* dep.config()).provider?.["gitlab"]
 
       const aiGatewayHeaders = {
-        "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
+        "User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
         "anthropic-beta": "context-1m-2025-08-07",
         ...providerConfig?.options?.aiGatewayHeaders,
       }
@@ -566,7 +545,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
           aiGatewayHeaders,
           featureFlags,
         },
-        async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: Record<string, any>) {
+        async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
           if (modelID.startsWith("duo-workflow-")) {
             const workflowRef = options?.workflowRef as string | undefined
             // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
@@ -701,7 +680,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
         options: {
           apiKey,
           headers: {
-            "User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
+            "User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
           },
         },
         async getModel(sdk: any, modelID: string) {
@@ -772,7 +751,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
         skipCache: input.options?.skipCache,
         collectLog: input.options?.collectLog,
         headers: {
-          "User-Agent": `opencode/${Installation.VERSION} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
+          "User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
         },
       }
 
@@ -1454,13 +1433,14 @@ const layer: Layer.Layer<
           return wrapSSE(res, chunkTimeout, chunkAbortCtl)
         }
 
-        const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
-        if (bundledFn) {
+        const bundledLoader = BUNDLED_PROVIDERS[model.api.npm]
+        if (bundledLoader) {
           log.info("using bundled provider", {
             providerID: model.providerID,
             pkg: model.api.npm,
           })
-          const loaded = bundledFn({
+          const factory = await bundledLoader()
+          const loaded = factory({
             name: model.providerID,
             ...options,
           })

+ 2 - 1
packages/opencode/src/server/instance/global.ts

@@ -10,6 +10,7 @@ import { AppRuntime } from "@/effect/app-runtime"
 import { AsyncQueue } from "@/util/queue"
 import { Instance } from "../../project/instance"
 import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
 import { Log } from "../../util"
 import { lazy } from "../../util/lazy"
 import { Config } from "../../config"
@@ -89,7 +90,7 @@ export const GlobalRoutes = lazy(() =>
         },
       }),
       async (c) => {
-        return c.json({ healthy: true, version: Installation.VERSION })
+        return c.json({ healthy: true, version: InstallationVersion })
       },
     )
     .get(

+ 2 - 1
packages/opencode/src/session/llm.ts

@@ -20,6 +20,7 @@ import { Wildcard } from "@/util"
 import { SessionID } from "@/session/schema"
 import { Auth } from "@/auth"
 import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
 import { EffectBridge } from "@/effect"
 import * as Option from "effect/Option"
 import * as OtelTracer from "@effect/opentelemetry/Tracer"
@@ -365,7 +366,7 @@ export namespace LLM {
               : {
                   "x-session-affinity": input.sessionID,
                   ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
-                  "User-Agent": `opencode/${Installation.VERSION}`,
+                  "User-Agent": `opencode/${InstallationVersion}`,
                 }),
             ...input.model.headers,
             ...headers,

+ 2 - 1
packages/opencode/src/session/session.ts

@@ -7,6 +7,7 @@ import z from "zod"
 import { type ProviderMetadata, type LanguageModelUsage } from "ai"
 import { Flag } from "../flag/flag"
 import { Installation } from "../installation"
+import { InstallationVersion } from "../installation/version"
 
 import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage"
 import { SyncEvent } from "../sync"
@@ -399,7 +400,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
       const result: Info = {
         id: SessionID.descending(input.id),
         slug: Slug.create(),
-        version: Installation.VERSION,
+        version: InstallationVersion,
         projectID: ctx.project.id,
         directory: input.directory,
         workspaceID: input.workspaceID,

+ 3 - 3
packages/opencode/src/storage/db.ts

@@ -11,7 +11,7 @@ import z from "zod"
 import path from "path"
 import { readFileSync, readdirSync, existsSync } from "fs"
 import { Flag } from "../flag/flag"
-import { CHANNEL } from "../installation/meta"
+import { InstallationChannel } from "../installation/version"
 import { InstanceState } from "@/effect"
 import { iife } from "@/util/iife"
 import { init } from "#db"
@@ -28,9 +28,9 @@ export const NotFoundError = NamedError.create(
 const log = Log.create({ service: "db" })
 
 export function getChannelPath() {
-  if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
+  if (["latest", "beta", "prod"].includes(InstallationChannel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
     return path.join(Global.Path.data, "opencode.db")
-  const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
+  const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
   return path.join(Global.Path.data, `opencode-${safe}.db`)
 }
 

+ 33 - 0
packages/opencode/src/temporary.ts

@@ -0,0 +1,33 @@
+import yargs from "yargs"
+import { TuiThreadCommand } from "./cli/cmd/tui/thread"
+import { InstallationVersion } from "./installation/version"
+import { hideBin } from "yargs/helpers"
+import { Log } from "./node"
+
+Log.init({
+  print: false,
+})
+
+const cli = yargs(hideBin(process.argv))
+  .parserConfiguration({ "populate--": true })
+  .scriptName("opencode")
+  .wrap(100)
+  .help("help", "show help")
+  .alias("help", "h")
+  .version("version", "show version number", InstallationVersion)
+  .alias("version", "v")
+  .option("print-logs", {
+    describe: "print logs to stderr",
+    type: "boolean",
+  })
+  .option("log-level", {
+    describe: "log level",
+    type: "string",
+    choices: ["DEBUG", "INFO", "WARN", "ERROR"],
+  })
+  .option("pure", {
+    describe: "run without external plugins",
+    type: "boolean",
+  })
+  .command(TuiThreadCommand)
+  .parse()

+ 2 - 2
packages/opencode/src/util/filesystem.ts

@@ -1,6 +1,5 @@
 import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises"
 import { createWriteStream, existsSync, statSync } from "fs"
-import { lookup } from "mime-types"
 import { realpathSync } from "fs"
 import { dirname, join, relative, resolve as pathResolve, win32 } from "path"
 import { Readable } from "stream"
@@ -101,7 +100,8 @@ export async function writeStream(
   }
 }
 
-export function mimeType(p: string): string {
+export async function mimeType(p: string): Promise<string> {
+  const { lookup } = await import("mime-types")
   return lookup(p) || "application/octet-stream"
 }
 

+ 13 - 9
packages/opencode/test/cli/tui/plugin-add.test.ts

@@ -4,7 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
 
@@ -31,15 +31,18 @@ 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({
+  const config: TuiConfig.Info = {
     plugin: [],
     plugin_origins: undefined,
-  })
+  }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.init({
+      api: createTuiPluginApi(),
+      config,
+    })
 
     await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
     await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
@@ -54,7 +57,6 @@ test("adds tui plugin at runtime from spec", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
     wait.mockRestore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
@@ -72,10 +74,10 @@ test("retries runtime add for file plugins after dependency wait", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const config: TuiConfig.Info = {
     plugin: [],
     plugin_origins: undefined,
-  })
+  }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
     await Bun.write(
       path.join(tmp.extra.mod, "index.ts"),
@@ -91,7 +93,10 @@ test("retries runtime add for file plugins after dependency wait", async () => {
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.init({
+      api: createTuiPluginApi(),
+      config,
+    })
 
     await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
     await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
@@ -100,7 +105,6 @@ test("retries runtime add for file plugins after dependency wait", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
     wait.mockRestore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }

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

@@ -4,7 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
 
@@ -50,11 +50,10 @@ 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>> = {
+  const config: TuiConfig.Info = {
     plugin: [],
     plugin_origins: undefined,
   }
-  const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const api = createTuiPluginApi({
@@ -69,7 +68,7 @@ test("installs plugin without loading it", async () => {
   })
 
   try {
-    await TuiPluginRuntime.init(api)
+    await TuiPluginRuntime.init({ api, config })
     const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
     expect(out).toMatchObject({
       ok: true,
@@ -82,7 +81,6 @@ test("installs plugin without loading it", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
     wait.mockRestore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }

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

@@ -39,10 +39,10 @@ test("runs onDispose callbacks with aborted signal and is idempotent", async ()
     },
   })
 
-  const restore = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
+  const { config, restore } = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     await TuiPluginRuntime.dispose()
 
     const marker = await fs.readFile(tmp.extra.marker, "utf8")
@@ -99,13 +99,13 @@ test("rolls back failed plugin and continues loading next", async () => {
     },
   })
 
-  const restore = mockTuiRuntime(tmp.path, [
+  const { config, restore } = mockTuiRuntime(tmp.path, [
     [tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }],
     [tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }],
   ])
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     // bad plugin's onDispose ran during rollback
     await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned")
     // good plugin still loaded
@@ -155,11 +155,11 @@ export default {
     },
   })
 
-  const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
+  const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
   const err = spyOn(console, "error").mockImplementation(() => {})
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
 
     const marker = await fs.readFile(tmp.extra.marker, "utf8")
     expect(marker).toContain("one")
@@ -202,10 +202,10 @@ test(
       },
     })
 
-    const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
+    const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
 
     try {
-      await TuiPluginRuntime.init(createTuiPluginApi())
+      await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
 
       const done = await new Promise<string>((resolve) => {
         const timer = setTimeout(() => resolve("timeout"), 7000)

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

@@ -4,7 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 import { Npm } from "../../../src/npm"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -44,7 +44,7 @@ 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({
+  const config: TuiConfig.Info = {
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_origins: [
       {
@@ -53,13 +53,13 @@ test("loads npm tui plugin from package ./tui export", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  })
+  }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
     const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
     expect(hit?.enabled).toBe(true)
@@ -69,7 +69,6 @@ test("loads npm tui plugin from package ./tui export", async () => {
     await TuiPluginRuntime.dispose()
     install.mockRestore()
     cwd.mockRestore()
-    get.mockRestore()
     wait.mockRestore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
@@ -106,7 +105,7 @@ 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({
+  const config: TuiConfig.Info = {
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -115,20 +114,19 @@ test("does not use npm package exports dot for tui entry", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  })
+  }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     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
   }
@@ -169,7 +167,7 @@ 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({
+  const config: TuiConfig.Info = {
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -178,13 +176,13 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  })
+  }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     // plugin code never ran
     await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
     // plugin not listed
@@ -193,7 +191,6 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
     await TuiPluginRuntime.dispose()
     install.mockRestore()
     cwd.mockRestore()
-    get.mockRestore()
     wait.mockRestore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
@@ -232,7 +229,7 @@ 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({
+  const config: TuiConfig.Info = {
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -241,20 +238,19 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  })
+  }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     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
   }
@@ -291,7 +287,7 @@ 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({
+  const config: TuiConfig.Info = {
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -300,7 +296,7 @@ test("does not use npm package main for tui entry", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  })
+  }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
@@ -308,7 +304,7 @@ test("does not use npm package main for tui entry", async () => {
   const error = spyOn(console, "error").mockImplementation(() => {})
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
     expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
     expect(error).not.toHaveBeenCalled()
@@ -317,7 +313,6 @@ test("does not use npm package main for tui entry", async () => {
     await TuiPluginRuntime.dispose()
     install.mockRestore()
     cwd.mockRestore()
-    get.mockRestore()
     wait.mockRestore()
     warn.mockRestore()
     error.mockRestore()
@@ -357,7 +352,7 @@ 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({
+  const config: TuiConfig.Info = {
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -366,18 +361,17 @@ test("does not use directory package main for tui entry", async () => {
         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 TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     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
   }
@@ -405,7 +399,7 @@ 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({
+  const config: TuiConfig.Info = {
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -414,18 +408,17 @@ test("uses directory index fallback for tui when package.json is missing", async
         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 TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     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
   }
@@ -463,7 +456,7 @@ 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({
+  const config: TuiConfig.Info = {
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_origins: [
       {
@@ -472,20 +465,19 @@ test("uses npm package name when tui plugin id is omitted", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  })
+  }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     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 - 5
packages/opencode/test/cli/tui/plugin-loader-pure.test.ts

@@ -4,7 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
 
@@ -37,7 +37,7 @@ test("skips external tui plugins in pure mode", async () => {
   process.env.OPENCODE_PURE = "1"
   process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
 
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const config: TuiConfig.Info = {
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_origins: [
       {
@@ -46,17 +46,16 @@ test("skips external tui plugins in pure mode", async () => {
         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 TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
     wait.mockRestore()
     if (pure === undefined) {
       delete process.env.OPENCODE_PURE

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

@@ -5,8 +5,8 @@ import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { Global } from "../../../src/global"
-import { TuiConfig } from "../../../src/config"
-import { Filesystem } from "../../../src/util"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
+import { Filesystem } from "../../../src/util/"
 
 const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -328,8 +328,55 @@ export default {
   try {
     expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
 
-    await TuiPluginRuntime.init(
-      createTuiPluginApi({
+    const localOpts = {
+      fn_marker: tmp.extra.fnMarker,
+      marker: tmp.extra.localMarker,
+      source: tmp.extra.localDest.replace(".opencode/themes/", ""),
+      dest: tmp.extra.localDest,
+      theme_path: `./${tmp.extra.localThemeFile}`,
+      theme_name: tmp.extra.localThemeName,
+      kv_key: "plugin_state_key",
+      session_id: "ses_test",
+      keybinds: { modal: "ctrl+alt+m", close: "q" },
+    }
+    const invalidOpts = {
+      marker: tmp.extra.invalidMarker,
+      theme_path: `./${tmp.extra.invalidThemeFile}`,
+      theme_name: tmp.extra.invalidThemeName,
+    }
+    const preloadedOpts = {
+      marker: tmp.extra.preloadedMarker,
+      dest: tmp.extra.preloadedDest,
+      theme_path: `./${tmp.extra.preloadedThemeFile}`,
+      theme_name: tmp.extra.preloadedThemeName,
+    }
+    const globalOpts = {
+      marker: tmp.extra.globalMarker,
+      theme_path: `./${tmp.extra.globalThemeFile}`,
+      theme_name: tmp.extra.globalThemeName,
+    }
+
+    const config: TuiConfig.Info = {
+      plugin: [
+        [tmp.extra.localSpec, localOpts],
+        [tmp.extra.invalidSpec, invalidOpts],
+        [tmp.extra.preloadedSpec, preloadedOpts],
+        [tmp.extra.globalSpec, globalOpts],
+      ],
+      plugin_origins: [
+        { spec: [tmp.extra.localSpec, localOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
+        { spec: [tmp.extra.invalidSpec, invalidOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
+        { spec: [tmp.extra.preloadedSpec, preloadedOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
+        {
+          spec: [tmp.extra.globalSpec, globalOpts],
+          scope: "global",
+          source: path.join(Global.Path.config, "tui.json"),
+        },
+      ],
+    }
+
+    await TuiPluginRuntime.init({
+      api: createTuiPluginApi({
         tuiConfig: {
           theme: "smoke",
           diff_style: "stacked",
@@ -366,7 +413,8 @@ export default {
           },
         },
       }),
-    )
+      config,
+    })
     const local = await row(tmp.extra.localMarker)
     const global = await row(tmp.extra.globalMarker)
     const invalid = await row(tmp.extra.invalidMarker)
@@ -459,7 +507,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const config: TuiConfig.Info = {
     plugin: [
       [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
       [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
@@ -477,12 +525,12 @@ test("continues loading when a plugin is missing config metadata", async () => {
         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 TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     // bad plugin was skipped (no metadata entry)
     await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
     // good plugin loaded fine
@@ -492,7 +540,6 @@ test("continues loading when a plugin is missing config metadata", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
     wait.mockRestore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
@@ -555,7 +602,18 @@ export default {
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
   try {
-    await TuiPluginRuntime.init(createTuiPluginApi())
+    const a = path.join(tmp.path, "order-a.ts")
+    const b = path.join(tmp.path, "order-b.ts")
+    const aSpec = pathToFileURL(a).href
+    const bSpec = pathToFileURL(b).href
+    const config: TuiConfig.Info = {
+      plugin: [aSpec, bSpec],
+      plugin_origins: [
+        { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
+        { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
+      ],
+    }
+    await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
     expect(lines).toEqual(["a-start", "a-end", "b"])
   } finally {
@@ -699,7 +757,7 @@ test("updates installed theme when plugin metadata changes", async () => {
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
 
-  const api = () =>
+  const mkApi = () =>
     createTuiPluginApi({
       theme: {
         has(name) {
@@ -708,8 +766,19 @@ test("updates installed theme when plugin metadata changes", async () => {
       },
     })
 
+  const mkConfig = (): TuiConfig.Info => ({
+    plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
+    plugin_origins: [
+      {
+        spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    ],
+  })
+
   try {
-    await TuiPluginRuntime.init(api())
+    await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
     await TuiPluginRuntime.dispose()
     await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
 
@@ -730,7 +799,7 @@ test("updates installed theme when plugin metadata changes", async () => {
     await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
     await fs.utimes(tmp.extra.themePath, stamp, stamp)
 
-    await TuiPluginRuntime.init(api())
+    await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
     const text = await fs.readFile(tmp.extra.dest, "utf8")
     expect(text).toContain("#222222")
     expect(text).not.toContain("#111111")

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

@@ -4,7 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
 
@@ -39,7 +39,7 @@ test("toggles plugin runtime state by exported id", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const config: TuiConfig.Info = {
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_enabled: {
       "demo.toggle": false,
@@ -51,13 +51,13 @@ test("toggles plugin runtime state by exported id", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  })
+  }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const api = createTuiPluginApi()
 
   try {
-    await TuiPluginRuntime.init(api)
+    await TuiPluginRuntime.init({ api, config })
 
     await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
     expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({
@@ -85,7 +85,6 @@ test("toggles plugin runtime state by exported id", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
     wait.mockRestore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
@@ -117,7 +116,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const config: TuiConfig.Info = {
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_enabled: {
       "demo.startup": false,
@@ -129,7 +128,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  })
+  }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const api = createTuiPluginApi()
@@ -138,7 +137,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
   })
 
   try {
-    await TuiPluginRuntime.init(api)
+    await TuiPluginRuntime.init({ api, config })
 
     await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on")
     expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({
@@ -152,7 +151,6 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
     wait.mockRestore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }

+ 1 - 10
packages/opencode/test/cli/tui/thread.test.ts

@@ -8,13 +8,11 @@ import { UI } from "../../../src/cli/ui"
 import * as Timeout from "../../../src/util/timeout"
 import * as Network from "../../../src/cli/network"
 import * as Win32 from "../../../src/cli/cmd/tui/win32"
-import { TuiConfig } from "../../../src/config"
-import { Instance } from "../../../src/project/instance"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 
 const stop = new Error("stop")
 const seen = {
   tui: [] as string[],
-  inst: [] as string[],
 }
 
 function setup() {
@@ -42,11 +40,6 @@ function setup() {
   })
   spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {})
   spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined)
-  spyOn(TuiConfig, "get").mockResolvedValue({})
-  spyOn(Instance, "provide").mockImplementation(async (input) => {
-    seen.inst.push(input.directory)
-    return input.fn()
-  })
 }
 
 describe("tui thread", () => {
@@ -86,7 +79,6 @@ describe("tui thread", () => {
     const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
     const type = process.platform === "win32" ? "junction" : "dir"
     seen.tui.length = 0
-    seen.inst.length = 0
     await fs.symlink(tmp.path, link, type)
 
     Object.defineProperty(process.stdin, "isTTY", {
@@ -105,7 +97,6 @@ describe("tui thread", () => {
       process.chdir(tmp.path)
       process.env.PWD = link
       await expect(call(project)).rejects.toBe(stop)
-      expect(seen.inst[0]).toBe(tmp.path)
       expect(seen.tui[0]).toBe(tmp.path)
     } finally {
       process.chdir(cwd)

+ 11 - 10
packages/opencode/test/config/config.test.ts

@@ -26,6 +26,7 @@ import { ProjectID } from "../../src/project/schema"
 import { Filesystem } from "../../src/util"
 import * as Network from "../../src/util/network"
 import { Npm } from "../../src/npm"
+import { ConfigPlugin } from "@/config/plugin"
 
 const emptyAccount = Layer.mock(Account.Service)({
   active: () => Effect.succeed(Option.none()),
@@ -1256,7 +1257,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
       const cfg = await load()
       const plugins = cfg.plugin ?? []
       const origins = cfg.plugin_origins ?? []
-      const names = plugins.map((item) => Config.pluginSpecifier(item))
+      const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item))
 
       expect(names).toContain("shared-plugin@2.0.0")
       expect(names).not.toContain("shared-plugin@1.0.0")
@@ -1264,7 +1265,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
       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")
+      const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
       expect(hit?.scope).toBe("local")
     },
   })
@@ -1909,8 +1910,8 @@ describe("resolvePluginSpec", () => {
   test("keeps package specs unchanged", async () => {
     await using tmp = await tmpdir()
     const file = path.join(tmp.path, "opencode.json")
-    expect(await Config.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
-    expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
+    expect(await ConfigPlugin.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
+    expect(await ConfigPlugin.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
   })
 
   test("resolves windows-style relative plugin directory specs", async () => {
@@ -1925,8 +1926,8 @@ 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", "index.ts")).href)
+    const hit = await ConfigPlugin.resolvePluginSpec(".\\plugin", file)
+    expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
   })
 
   test("resolves relative file plugin paths to file urls", async () => {
@@ -1937,8 +1938,8 @@ describe("resolvePluginSpec", () => {
     })
 
     const file = path.join(tmp.path, "opencode.json")
-    const hit = await Config.resolvePluginSpec("./plugin.ts", file)
-    expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
+    const hit = await ConfigPlugin.resolvePluginSpec("./plugin.ts", file)
+    expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
   })
 
   test("resolves plugin directory paths to directory urls", async () => {
@@ -1956,8 +1957,8 @@ 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)
+    const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
+    expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
   })
 
   test("resolves plugin directories without package.json to index.ts", async () => {

+ 0 - 0
packages/opencode/test/config/plugin.test.ts


+ 158 - 335
packages/opencode/test/config/tui.test.ts

@@ -3,13 +3,15 @@ import path from "path"
 import fs from "fs/promises"
 import { tmpdir } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
+import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
 import { Config } from "../../src/config"
-import { TuiConfig } from "../../src/config"
 import { Global } from "../../src/global"
 import { Filesystem } from "../../src/util"
 import { AppRuntime } from "../../src/effect/app-runtime"
+import { Effect, Layer } from "effect"
+import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd"
+import { ConfigPlugin } from "@/config/plugin"
 
-const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
 const wintest = process.platform === "win32" ? test : test.skip
 const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
 const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
@@ -18,6 +20,13 @@ beforeEach(async () => {
   await clear(true)
 })
 
+const getTuiConfig = async (directory: string) =>
+  Effect.runPromise(
+    TuiConfig.Service.use((svc) => svc.get()).pipe(
+      Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))),
+    ),
+  )
+
 afterEach(async () => {
   delete process.env.OPENCODE_CONFIG
   delete process.env.OPENCODE_TUI_CONFIG
@@ -25,7 +34,6 @@ afterEach(async () => {
   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 clear(true)
 })
 
@@ -83,9 +91,9 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
     directory: tmp.path,
     fn: async () => {
       const server = await load()
-      const tui = await TuiConfig.get()
-      const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
-      const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))
+      const tui = await getTuiConfig(tmp.path)
+      const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
+      const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
 
       expect(serverPlugins).toEqual(tuiPlugins)
       expect(serverPlugins).toContain("shared-plugin@2.0.0")
@@ -93,8 +101,8 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
 
       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) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(serverPlugins)
+      expect(tuiOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
       expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope))
     },
   })
@@ -113,14 +121,9 @@ test("loads tui config with the same precedence order as server config paths", a
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.theme).toBe("local")
-      expect(config.diff_style).toBe("stacked")
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.theme).toBe("local")
+  expect(config.diff_style).toBe("stacked")
 })
 
 test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
@@ -141,26 +144,21 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.theme).toBe("migrated-theme")
-      expect(config.scroll_speed).toBe(5)
-      expect(config.keybinds?.app_exit).toBe("ctrl+q")
-      const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
-      expect(JSON.parse(text)).toMatchObject({
-        theme: "migrated-theme",
-        scroll_speed: 5,
-      })
-      const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
-      expect(server.theme).toBeUndefined()
-      expect(server.keybinds).toBeUndefined()
-      expect(server.tui).toBeUndefined()
-      expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
-      expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
-    },
+  const config = await getTuiConfig(tmp.path)
+  expect(config.theme).toBe("migrated-theme")
+  expect(config.scroll_speed).toBe(5)
+  expect(config.keybinds?.app_exit).toBe("ctrl+q")
+  const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
+  expect(JSON.parse(text)).toMatchObject({
+    theme: "migrated-theme",
+    scroll_speed: 5,
   })
+  const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
+  expect(server.theme).toBeUndefined()
+  expect(server.keybinds).toBeUndefined()
+  expect(server.tui).toBeUndefined()
+  expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
+  expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
 })
 
 test("migrates project legacy tui keys even when global tui.json already exists", async () => {
@@ -181,19 +179,14 @@ test("migrates project legacy tui keys even when global tui.json already exists"
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.theme).toBe("project-migrated")
-      expect(config.scroll_speed).toBe(2)
-      expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
-
-      const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
-      expect(server.theme).toBeUndefined()
-      expect(server.tui).toBeUndefined()
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.theme).toBe("project-migrated")
+  expect(config.scroll_speed).toBe(2)
+  expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
+
+  const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
+  expect(server.theme).toBeUndefined()
+  expect(server.tui).toBeUndefined()
 })
 
 test("drops unknown legacy tui keys during migration", async () => {
@@ -213,19 +206,14 @@ test("drops unknown legacy tui keys during migration", async () => {
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.theme).toBe("migrated-theme")
-      expect(config.scroll_speed).toBe(2)
-
-      const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
-      const migrated = JSON.parse(text)
-      expect(migrated.scroll_speed).toBe(2)
-      expect(migrated.foo).toBeUndefined()
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.theme).toBe("migrated-theme")
+  expect(config.scroll_speed).toBe(2)
+
+  const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
+  const migrated = JSON.parse(text)
+  expect(migrated.scroll_speed).toBe(2)
+  expect(migrated.foo).toBeUndefined()
 })
 
 test("skips migration when opencode.jsonc is syntactically invalid", async () => {
@@ -242,19 +230,14 @@ test("skips migration when opencode.jsonc is syntactically invalid", async () =>
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.theme).toBeUndefined()
-      expect(config.scroll_speed).toBeUndefined()
-      expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
-      expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
-      const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
-      expect(source).toContain('"theme": "broken-theme"')
-      expect(source).toContain('"tui": { "scroll_speed": 2 }')
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.theme).toBeUndefined()
+  expect(config.scroll_speed).toBeUndefined()
+  expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
+  expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
+  const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
+  expect(source).toContain('"theme": "broken-theme"')
+  expect(source).toContain('"tui": { "scroll_speed": 2 }')
 })
 
 test("skips migration when tui.json already exists", async () => {
@@ -265,18 +248,13 @@ test("skips migration when tui.json already exists", async () => {
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.diff_style).toBe("stacked")
-      expect(config.theme).toBeUndefined()
+  const config = await getTuiConfig(tmp.path)
+  expect(config.diff_style).toBe("stacked")
+  expect(config.theme).toBeUndefined()
 
-      const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
-      expect(server.theme).toBe("legacy")
-      expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
-    },
-  })
+  const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
+  expect(server.theme).toBe("legacy")
+  expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
 })
 
 test("continues loading tui config when legacy source cannot be stripped", async () => {
@@ -290,17 +268,12 @@ test("continues loading tui config when legacy source cannot be stripped", async
   await fs.chmod(source, 0o444)
 
   try {
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const config = await TuiConfig.get()
-        expect(config.theme).toBe("readonly-theme")
-        expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
-
-        const server = JSON.parse(await Filesystem.readText(source))
-        expect(server.theme).toBe("readonly-theme")
-      },
-    })
+    const config = await getTuiConfig(tmp.path)
+    expect(config.theme).toBe("readonly-theme")
+    expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
+
+    const server = JSON.parse(await Filesystem.readText(source))
+    expect(server.theme).toBe("readonly-theme")
   } finally {
     await fs.chmod(source, 0o644)
   }
@@ -323,17 +296,12 @@ test("migration backup preserves JSONC comments", async () => {
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      await TuiConfig.get()
-      const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))
-      expect(backup).toContain("// top-level comment")
-      expect(backup).toContain("// nested comment")
-      expect(backup).toContain('"theme": "jsonc-theme"')
-      expect(backup).toContain('"scroll_speed": 1.5')
-    },
-  })
+  await getTuiConfig(tmp.path)
+  const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))
+  expect(backup).toContain("// top-level comment")
+  expect(backup).toContain("// nested comment")
+  expect(backup).toContain('"theme": "jsonc-theme"')
+  expect(backup).toContain('"scroll_speed": 1.5')
 })
 
 test("migrates legacy tui keys across multiple opencode.json levels", async () => {
@@ -345,16 +313,10 @@ test("migrates legacy tui keys across multiple opencode.json levels", async () =
       await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
     },
   })
-
-  await Instance.provide({
-    directory: path.join(tmp.path, "apps", "client"),
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.theme).toBe("nested-theme")
-      expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
-      expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true)
-    },
-  })
+  const config = await getTuiConfig(path.join(tmp.path, "apps", "client"))
+  expect(config.theme).toBe("nested-theme")
+  expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
+  expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true)
 })
 
 test("flattens nested tui key inside tui.json", async () => {
@@ -370,16 +332,11 @@ test("flattens nested tui key inside tui.json", async () => {
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.scroll_speed).toBe(3)
-      expect(config.diff_style).toBe("stacked")
-      // top-level keys take precedence over nested tui keys
-      expect(config.theme).toBe("outer")
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.scroll_speed).toBe(3)
+  expect(config.diff_style).toBe("stacked")
+  // top-level keys take precedence over nested tui keys
+  expect(config.theme).toBe("outer")
 })
 
 test("top-level keys in tui.json take precedence over nested tui key", async () => {
@@ -395,14 +352,9 @@ test("top-level keys in tui.json take precedence over nested tui key", async ()
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.diff_style).toBe("auto")
-      expect(config.scroll_speed).toBe(2)
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.diff_style).toBe("auto")
+  expect(config.scroll_speed).toBe(2)
 })
 
 test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => {
@@ -415,16 +367,11 @@ test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      // project tui.json overrides the custom path, same as server config precedence
-      expect(config.theme).toBe("project")
-      // project also set diff_style, so that wins
-      expect(config.diff_style).toBe("auto")
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  // project tui.json overrides the custom path, same as server config precedence
+  expect(config.theme).toBe("project")
+  // project also set diff_style, so that wins
+  expect(config.diff_style).toBe("auto")
 })
 
 test("merges keybind overrides across precedence layers", async () => {
@@ -434,28 +381,16 @@ test("merges keybind overrides across precedence layers", async () => {
       await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
     },
   })
-
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.keybinds?.app_exit).toBe("ctrl+q")
-      expect(config.keybinds?.theme_list).toBe("ctrl+k")
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.keybinds?.app_exit).toBe("ctrl+q")
+  expect(config.keybinds?.theme_list).toBe("ctrl+k")
 })
 
 wintest("defaults Ctrl+Z to input undo on Windows", async () => {
   await using tmp = await tmpdir()
-
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.keybinds?.terminal_suspend).toBe("none")
-      expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.keybinds?.terminal_suspend).toBe("none")
+  expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
 })
 
 wintest("keeps explicit input undo overrides on Windows", async () => {
@@ -464,15 +399,9 @@ wintest("keeps explicit input undo overrides on Windows", async () => {
       await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } }))
     },
   })
-
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.keybinds?.terminal_suspend).toBe("none")
-      expect(config.keybinds?.input_undo).toBe("ctrl+y")
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.keybinds?.terminal_suspend).toBe("none")
+  expect(config.keybinds?.input_undo).toBe("ctrl+y")
 })
 
 wintest("ignores terminal suspend bindings on Windows", async () => {
@@ -482,14 +411,9 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.keybinds?.terminal_suspend).toBe("none")
-      expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.keybinds?.terminal_suspend).toBe("none")
+  expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
 })
 
 test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
@@ -500,15 +424,9 @@ test("OPENCODE_TUI_CONFIG provides settings when no project config exists", asyn
       process.env.OPENCODE_TUI_CONFIG = custom
     },
   })
-
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.theme).toBe("from-env")
-      expect(config.diff_style).toBe("stacked")
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.theme).toBe("from-env")
+  expect(config.diff_style).toBe("stacked")
 })
 
 test("does not derive tui path from OPENCODE_CONFIG", async () => {
@@ -521,14 +439,8 @@ test("does not derive tui path from OPENCODE_CONFIG", async () => {
       process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
     },
   })
-
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.theme).toBeUndefined()
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.theme).toBeUndefined()
 })
 
 test("applies env and file substitutions in tui.json", async () => {
@@ -547,15 +459,9 @@ test("applies env and file substitutions in tui.json", async () => {
         )
       },
     })
-
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const config = await TuiConfig.get()
-        expect(config.theme).toBe("env-theme")
-        expect(config.keybinds?.app_exit).toBe("ctrl+q")
-      },
-    })
+    const config = await getTuiConfig(tmp.path)
+    expect(config.theme).toBe("env-theme")
+    expect(config.keybinds?.app_exit).toBe("ctrl+q")
   } finally {
     if (original === undefined) delete process.env.TUI_THEME_TEST
     else process.env.TUI_THEME_TEST = original
@@ -575,46 +481,8 @@ test("applies file substitutions when first identical token is in a commented li
       )
     },
   })
-
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.theme).toBe("resolved-theme")
-    },
-  })
-})
-
-test("loads managed tui config and gives it highest precedence", async () => {
-  await using tmp = await tmpdir({
-    init: async (dir) => {
-      await Bun.write(
-        path.join(dir, "tui.json"),
-        JSON.stringify({ theme: "project-theme", plugin: ["shared-plugin@1.0.0"] }, null, 2),
-      )
-      await fs.mkdir(managedConfigDir, { recursive: true })
-      await Bun.write(
-        path.join(managedConfigDir, "tui.json"),
-        JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2),
-      )
-    },
-  })
-
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.theme).toBe("managed-theme")
-      expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
-      expect(config.plugin_origins).toEqual([
-        {
-          spec: "shared-plugin@2.0.0",
-          scope: "global",
-          source: path.join(managedConfigDir, "tui.json"),
-        },
-      ])
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.theme).toBe("resolved-theme")
 })
 
 test("loads .opencode/tui.json", async () => {
@@ -624,33 +492,8 @@ test("loads .opencode/tui.json", async () => {
       await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
     },
   })
-
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.diff_style).toBe("stacked")
-    },
-  })
-})
-
-test("gracefully falls back when tui.json has invalid JSON", async () => {
-  await using tmp = await tmpdir({
-    init: async (dir) => {
-      await Bun.write(path.join(dir, "tui.json"), "{ invalid json }")
-      await fs.mkdir(managedConfigDir, { recursive: true })
-      await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2))
-    },
-  })
-
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.theme).toBe("managed-fallback")
-      expect(config.keybinds).toBeDefined()
-    },
-  })
+  const config = await getTuiConfig(tmp.path)
+  expect(config.diff_style).toBe("stacked")
 })
 
 test("supports tuple plugin specs with options in tui.json", async () => {
@@ -665,20 +508,15 @@ test("supports tuple plugin specs with options in tui.json", async () => {
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
-      expect(config.plugin_origins).toEqual([
-        {
-          spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
-          scope: "local",
-          source: path.join(tmp.path, "tui.json"),
-        },
-      ])
+  const config = await getTuiConfig(tmp.path)
+  expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
+  expect(config.plugin_origins).toEqual([
+    {
+      spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
+      scope: "local",
+      source: path.join(tmp.path, "tui.json"),
     },
-  })
+  ])
 })
 
 test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
@@ -702,28 +540,23 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.plugin).toEqual([
-        ["acme-plugin@2.0.0", { source: "project" }],
-        ["second-plugin@3.0.0", { source: "project" }],
-      ])
-      expect(config.plugin_origins).toEqual([
-        {
-          spec: ["acme-plugin@2.0.0", { source: "project" }],
-          scope: "local",
-          source: path.join(tmp.path, "tui.json"),
-        },
-        {
-          spec: ["second-plugin@3.0.0", { source: "project" }],
-          scope: "local",
-          source: path.join(tmp.path, "tui.json"),
-        },
-      ])
+  const config = await getTuiConfig(tmp.path)
+  expect(config.plugin).toEqual([
+    ["acme-plugin@2.0.0", { source: "project" }],
+    ["second-plugin@3.0.0", { source: "project" }],
+  ])
+  expect(config.plugin_origins).toEqual([
+    {
+      spec: ["acme-plugin@2.0.0", { source: "project" }],
+      scope: "local",
+      source: path.join(tmp.path, "tui.json"),
     },
-  })
+    {
+      spec: ["second-plugin@3.0.0", { source: "project" }],
+      scope: "local",
+      source: path.join(tmp.path, "tui.json"),
+    },
+  ])
 })
 
 test("tracks global and local plugin metadata in merged tui config", async () => {
@@ -744,25 +577,20 @@ test("tracks global and local plugin metadata in merged tui config", async () =>
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
-      expect(config.plugin_origins).toEqual([
-        {
-          spec: "global-plugin@1.0.0",
-          scope: "global",
-          source: path.join(Global.Path.config, "tui.json"),
-        },
-        {
-          spec: "local-plugin@2.0.0",
-          scope: "local",
-          source: path.join(tmp.path, "tui.json"),
-        },
-      ])
+  const config = await getTuiConfig(tmp.path)
+  expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
+  expect(config.plugin_origins).toEqual([
+    {
+      spec: "global-plugin@1.0.0",
+      scope: "global",
+      source: path.join(Global.Path.config, "tui.json"),
     },
-  })
+    {
+      spec: "local-plugin@2.0.0",
+      scope: "local",
+      source: path.join(tmp.path, "tui.json"),
+    },
+  ])
 })
 
 test("merges plugin_enabled flags across config layers", async () => {
@@ -789,15 +617,10 @@ test("merges plugin_enabled flags across config layers", async () => {
     },
   })
 
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const config = await TuiConfig.get()
-      expect(config.plugin_enabled).toEqual({
-        "internal:sidebar-context": false,
-        "demo.plugin": false,
-        "local.plugin": true,
-      })
-    },
+  const config = await getTuiConfig(tmp.path)
+  expect(config.plugin_enabled).toEqual({
+    "internal:sidebar-context": false,
+    "demo.plugin": false,
+    "local.plugin": true,
   })
 })

+ 2 - 2
packages/opencode/test/file/index.test.ts

@@ -140,7 +140,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          expect(Filesystem.mimeType(filepath)).toContain("application/json")
+          expect(await Filesystem.mimeType(filepath)).toContain("application/json")
 
           const result = await read("test.json")
           expect(result.type).toBe("text")
@@ -164,7 +164,7 @@ describe("file/index Filesystem patterns", () => {
         await Instance.provide({
           directory: tmp.path,
           fn: async () => {
-            expect(Filesystem.mimeType(filepath)).toContain(mime)
+            expect(await Filesystem.mimeType(filepath)).toContain(mime)
           },
         })
       }

+ 15 - 11
packages/opencode/test/fixture/tui-runtime.ts

@@ -1,27 +1,31 @@
 import { spyOn } from "bun:test"
 import path from "path"
-import { TuiConfig } from "../../src/config"
+import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
 
 type PluginSpec = string | [string, Record<string, unknown>]
 
-export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
+export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record<string, boolean> }) {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
   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_origins,
-  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
 
-  return () => {
-    cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
-    delete process.env.OPENCODE_PLUGIN_META_FILE
+  const config: TuiConfig.Info = {
+    plugin,
+    plugin_origins,
+    ...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }),
+  }
+
+  return {
+    config,
+    restore: () => {
+      cwd.mockRestore()
+      wait.mockRestore()
+      delete process.env.OPENCODE_PLUGIN_META_FILE
+    },
   }
 }

+ 3 - 3
packages/opencode/test/storage/db.test.ts

@@ -1,14 +1,14 @@
 import { describe, expect, test } from "bun:test"
 import path from "path"
 import { Global } from "../../src/global"
-import { Installation } from "../../src/installation"
+import { InstallationChannel } from "../../src/installation/version"
 import { Database } from "../../src/storage"
 
 describe("Database.Path", () => {
   test("returns database path for the current channel", () => {
-    const expected = ["latest", "beta"].includes(Installation.CHANNEL)
+    const expected = ["latest", "beta"].includes(InstallationChannel)
       ? path.join(Global.Path.data, "opencode.db")
-      : path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
+      : path.join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
     expect(Database.getChannelPath()).toBe(expected)
   })
 })

+ 1 - 0
packages/opencode/test/tool/read.test.ts

@@ -16,6 +16,7 @@ import { Tool } from "../../src/tool"
 import { Filesystem } from "../../src/util"
 import { provideInstance, tmpdirScoped } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
+import { Npm } from "@opencode-ai/shared/npm"
 
 const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
 

+ 13 - 13
packages/opencode/test/util/filesystem.test.ts

@@ -347,31 +347,31 @@ describe("filesystem", () => {
   })
 
   describe("mimeType()", () => {
-    test("returns correct MIME type for JSON", () => {
-      expect(Filesystem.mimeType("test.json")).toContain("application/json")
+    test("returns correct MIME type for JSON", async () => {
+      expect(await Filesystem.mimeType("test.json")).toContain("application/json")
     })
 
-    test("returns correct MIME type for JavaScript", () => {
-      expect(Filesystem.mimeType("test.js")).toContain("javascript")
+    test("returns correct MIME type for JavaScript", async () => {
+      expect(await Filesystem.mimeType("test.js")).toContain("javascript")
     })
 
-    test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", () => {
-      const mime = Filesystem.mimeType("test.ts")
+    test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", async () => {
+      const mime = await Filesystem.mimeType("test.ts")
       // .ts is ambiguous: TypeScript vs MPEG-2 TS video
       expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true)
     })
 
-    test("returns correct MIME type for images", () => {
-      expect(Filesystem.mimeType("test.png")).toContain("image/png")
-      expect(Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
+    test("returns correct MIME type for images", async () => {
+      expect(await Filesystem.mimeType("test.png")).toContain("image/png")
+      expect(await Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
     })
 
-    test("returns default for unknown extension", () => {
-      expect(Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
+    test("returns default for unknown extension", async () => {
+      expect(await Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
     })
 
-    test("handles files without extension", () => {
-      expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
+    test("handles files without extension", async () => {
+      expect(await Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
     })
   })
 

+ 4 - 0
packages/opencode/time.ts

@@ -0,0 +1,4 @@
+import path from "path"
+const toDynamicallyImport = path.join(process.cwd(), process.argv[2])
+await import(toDynamicallyImport)
+console.log(performance.now())

+ 153 - 0
packages/opencode/trace-imports.ts

@@ -0,0 +1,153 @@
+#!/usr/bin/env bun
+import * as path from "path"
+import * as ts from "typescript"
+
+const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode"
+
+// Get entry file from command line arg or use default
+const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts"
+
+const visited = new Set<string>()
+
+function resolveImport(importPath: string, fromFile: string): string | null {
+  if (importPath.startsWith("@/")) {
+    return path.join(BASE_DIR, "src", importPath.slice(2))
+  }
+
+  if (importPath.startsWith("./") || importPath.startsWith("../")) {
+    const dir = path.dirname(fromFile)
+    return path.resolve(dir, importPath)
+  }
+
+  return null
+}
+
+function isInternalImport(importPath: string): boolean {
+  return importPath.startsWith("@/") || importPath.startsWith("./") || importPath.startsWith("../")
+}
+
+async function tryExtensions(filePath: string): Promise<string | null> {
+  const extensions = [".ts", ".tsx", ".js", ".jsx"]
+
+  try {
+    const file = Bun.file(filePath)
+    const stat = await file.stat()
+
+    if (stat?.isDirectory()) {
+      for (const ext of extensions) {
+        const indexPath = path.join(filePath, "index" + ext)
+        const indexFile = Bun.file(indexPath)
+        if (await indexFile.exists()) return indexPath
+      }
+      return null
+    }
+
+    // It's a file
+    return filePath
+  } catch {
+    // Path doesn't exist, try adding extensions
+    for (const ext of extensions) {
+      const withExt = filePath + ext
+      const extFile = Bun.file(withExt)
+      if (await extFile.exists()) return withExt
+    }
+    return null
+  }
+}
+
+function extractImports(sourceFile: ts.SourceFile): string[] {
+  const imports: string[] = []
+
+  function visit(node: ts.Node) {
+    // import x from "path" or import { x } from "path"
+    if (ts.isImportDeclaration(node)) {
+      // Skip type-only imports
+      if (node.importClause?.isTypeOnly) return
+
+      const moduleSpec = node.moduleSpecifier
+      if (ts.isStringLiteral(moduleSpec)) {
+        imports.push(moduleSpec.text)
+      }
+    }
+
+    // export { x } from "path"
+    if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
+      if (ts.isStringLiteral(node.moduleSpecifier)) {
+        imports.push(node.moduleSpecifier.text)
+      }
+    }
+
+    // Dynamic import: import("path")
+    if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
+      const arg = node.arguments[0]
+      if (arg && ts.isStringLiteral(arg)) {
+        imports.push(arg.text)
+      }
+    }
+
+    ts.forEachChild(node, visit)
+  }
+
+  visit(sourceFile)
+  return imports
+}
+
+async function traceFile(filePath: string, depth = 0): Promise<void> {
+  const normalizedPath = path.relative(BASE_DIR, filePath)
+
+  if (visited.has(filePath)) {
+    return
+  }
+
+  // Only trace TypeScript/JavaScript files
+  if (!filePath.match(/\.(ts|tsx|js|jsx)$/)) {
+    return
+  }
+
+  visited.add(filePath)
+  console.log("\t".repeat(depth) + normalizedPath)
+
+  let content: string
+  try {
+    content = await Bun.file(filePath).text()
+  } catch {
+    return
+  }
+
+  const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true)
+
+  const imports = extractImports(sourceFile)
+  const internalImports = imports.filter(isInternalImport)
+  const externalImports = imports.filter((imp) => !isInternalImport(imp))
+
+  // Print external imports
+  for (const imp of externalImports) {
+    console.log("\t".repeat(depth + 1) + `[ext] ${imp}`)
+  }
+
+  for (const imp of internalImports) {
+    const resolved = resolveImport(imp, filePath)
+    if (!resolved) continue
+
+    const actualPath = await tryExtensions(resolved)
+    if (!actualPath) continue
+
+    await traceFile(actualPath, depth + 1)
+  }
+}
+
+async function main() {
+  const entryPath = path.join(BASE_DIR, ENTRY_FILE)
+
+  // Check if file exists
+  const file = Bun.file(entryPath)
+  if (!(await file.exists())) {
+    console.error(`File not found: ${ENTRY_FILE}`)
+    console.error(`Resolved to: ${entryPath}`)
+    process.exit(1)
+  }
+
+  await traceFile(entryPath)
+}
+
+main().catch(console.error)

+ 2 - 1
packages/opencode/tsconfig.json

@@ -10,7 +10,8 @@
     "customConditions": ["browser"],
     "paths": {
       "@/*": ["./src/*"],
-      "@tui/*": ["./src/cli/cmd/tui/*"]
+      "@tui/*": ["./src/cli/cmd/tui/*"],
+      "@test/*": ["./test/*"]
     },
     "plugins": [
       {

+ 5 - 2
packages/shared/package.json

@@ -6,7 +6,8 @@
   "license": "MIT",
   "private": true,
   "scripts": {
-    "test": "bun test"
+    "test": "bun test",
+    "typecheck": "tsgo --noEmit"
   },
   "bin": {
     "opencode": "./bin/opencode"
@@ -17,7 +18,9 @@
   "imports": {},
   "devDependencies": {
     "@types/semver": "catalog:",
-    "@types/bun": "catalog:"
+    "@types/bun": "catalog:",
+    "@types/npmcli__arborist": "6.3.3",
+    "@tsconfig/bun": "catalog:"
   },
   "dependencies": {
     "@effect/platform-node": "catalog:",

+ 20 - 5
packages/shared/src/npm.ts

@@ -1,6 +1,5 @@
 import path from "path"
 import semver from "semver"
-import { Arborist } from "@npmcli/arborist"
 import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
 import { NodeFileSystem } from "@effect/platform-node"
 import { AppFileSystem } from "./filesystem"
@@ -19,8 +18,8 @@ export namespace Npm {
   }
 
   export interface Interface {
-    readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError>
-    readonly install: (dir: string) => Effect.Effect<void>
+    readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
+    readonly install: (dir: string, input?: { add: string[] }) => Effect.Effect<void, EffectFlock.LockError>
     readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
     readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
   }
@@ -92,6 +91,7 @@ export namespace Npm {
       })
 
       const add = Effect.fn("Npm.add")(function* (pkg: string) {
+        const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
         const dir = directory(pkg)
         yield* flock.acquire(`npm-install:${dir}`)
 
@@ -133,10 +133,17 @@ export namespace Npm {
         return resolveEntryPoint(first.name, first.path)
       }, Effect.scoped)
 
-      const install = Effect.fn("Npm.install")(function* (dir: string) {
+      const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
+        const canWrite = yield* afs.access(dir, { writable: true }).pipe(
+          Effect.as(true),
+          Effect.orElseSucceed(() => false),
+        )
+        if (!canWrite) return
+
         yield* flock.acquire(`npm-install:${dir}`)
 
         const reify = Effect.fnUntraced(function* () {
+          const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
           const arb = new Arborist({
             path: dir,
             binLinks: true,
@@ -145,7 +152,14 @@ export namespace Npm {
             ignoreScripts: true,
           })
           yield* Effect.tryPromise({
-            try: () => arb.reify().catch(() => {}),
+            try: () =>
+              arb
+                .reify({
+                  add: input?.add || [],
+                  save: true,
+                  saveType: "prod",
+                })
+                .catch(() => {}),
             catch: () => {},
           }).pipe(Effect.orElseSucceed(() => {}))
         })
@@ -167,6 +181,7 @@ export namespace Npm {
           ...Object.keys(pkgAny?.devDependencies || {}),
           ...Object.keys(pkgAny?.peerDependencies || {}),
           ...Object.keys(pkgAny?.optionalDependencies || {}),
+          ...(input?.add || []),
         ])
 
         const root = lockAny?.packages?.[""] || {}

+ 6 - 0
packages/shared/src/util/error.ts

@@ -4,6 +4,12 @@ export abstract class NamedError extends Error {
   abstract schema(): z.core.$ZodType
   abstract toObject(): { name: string; data: any }
 
+  static hasName(error: unknown, name: string): boolean {
+    return (
+      typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
+    )
+  }
+
   static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) {
     const schema = z
       .object({

+ 7 - 3
packages/shared/src/util/flock.ts

@@ -345,10 +345,14 @@ export namespace Flock {
     return await fn()
   }
 
-  export const effect = Effect.fn("Flock.effect")(function* (key: string) {
+  export const effect = Effect.fn("Flock.effect")(function* (key: string, input: Options = {}) {
     return yield* Effect.acquireRelease(
-      Effect.promise((signal) => Flock.acquire(key, { signal })),
-      (foo) => Effect.promise(() => foo.release()),
+      Effect.promise((signal) => Flock.acquire(key, { ...input, signal })).pipe(
+        Effect.withSpan("Flock.acquire", {
+          attributes: { key },
+        }),
+      ),
+      (lock) => Effect.promise(() => lock.release()).pipe(Effect.withSpan("Flock.release")),
     ).pipe(Effect.asVoid)
   })
 }

+ 0 - 9
packages/shared/tsconfig.json

@@ -2,16 +2,7 @@
   "$schema": "https://json.schemastore.org/tsconfig",
   "extends": "@tsconfig/bun/tsconfig.json",
   "compilerOptions": {
-    "jsx": "preserve",
-    "jsxImportSource": "@opentui/solid",
-    "lib": ["ESNext", "DOM", "DOM.Iterable"],
-    "types": [],
     "noUncheckedIndexedAccess": false,
-    "customConditions": ["browser"],
-    "paths": {
-      "@/*": ["./src/*"],
-      "@tui/*": ["./src/cli/cmd/tui/*"]
-    },
     "plugins": [
       {
         "name": "@effect/language-service",