Browse Source

tui plugins (#19347)

Sebastian 4 tháng trước cách đây
mục cha
commit
6274b0677c
91 tập tin đã thay đổi với 10544 bổ sung898 xóa
  1. 223 0
      .opencode/plugins/smoke-theme.json
  2. 852 0
      .opencode/plugins/tui-smoke.tsx
  3. 1 0
      .opencode/themes/.gitignore
  4. 19 0
      .opencode/tui.json
  5. 13 3
      bun.lock
  6. 3 1
      packages/app/src/components/status-popover-body.tsx
  7. 1 1
      packages/opencode/bunfig.toml
  8. 3 2
      packages/opencode/script/build.ts
  9. 377 0
      packages/opencode/specs/tui-plugins.md
  10. 6 5
      packages/opencode/src/bun/index.ts
  11. 6 0
      packages/opencode/src/bun/registry.ts
  12. 3 2
      packages/opencode/src/cli/cmd/db.ts
  13. 231 0
      packages/opencode/src/cli/cmd/plug.ts
  14. 183 182
      packages/opencode/src/cli/cmd/tui/app.tsx
  15. 30 6
      packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
  16. 2 1
      packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
  17. 13 20
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
  18. 91 0
      packages/opencode/src/cli/cmd/tui/component/error-component.tsx
  19. 14 0
      packages/opencode/src/cli/cmd/tui/component/plugin-route-missing.tsx
  20. 63 0
      packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx
  21. 2 1
      packages/opencode/src/cli/cmd/tui/context/exit.tsx
  22. 12 9
      packages/opencode/src/cli/cmd/tui/context/keybind.tsx
  23. 41 0
      packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts
  24. 7 2
      packages/opencode/src/cli/cmd/tui/context/route.tsx
  25. 3 0
      packages/opencode/src/cli/cmd/tui/context/sdk.tsx
  26. 132 110
      packages/opencode/src/cli/cmd/tui/context/theme.tsx
  27. 1 1
      packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx
  28. 48 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx
  29. 61 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
  30. 60 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx
  31. 91 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx
  32. 64 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx
  33. 94 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx
  34. 46 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx
  35. 262 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx
  36. 406 0
      packages/opencode/src/cli/cmd/tui/plugin/api.tsx
  37. 3 0
      packages/opencode/src/cli/cmd/tui/plugin/index.ts
  38. 25 0
      packages/opencode/src/cli/cmd/tui/plugin/internal.ts
  39. 972 0
      packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
  40. 61 0
      packages/opencode/src/cli/cmd/tui/plugin/slots.tsx
  41. 9 39
      packages/opencode/src/cli/cmd/tui/routes/home.tsx
  42. 0 1
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  43. 25 278
      packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
  44. 3 2
      packages/opencode/src/cli/cmd/tui/thread.ts
  45. 14 4
      packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
  46. 2 13
      packages/opencode/src/cli/error.ts
  47. 108 65
      packages/opencode/src/config/config.ts
  48. 2 0
      packages/opencode/src/config/tui-schema.ts
  49. 121 27
      packages/opencode/src/config/tui.ts
  50. 25 0
      packages/opencode/src/flag/flag.ts
  51. 14 3
      packages/opencode/src/index.ts
  52. 144 45
      packages/opencode/src/plugin/index.ts
  53. 351 0
      packages/opencode/src/plugin/install.ts
  54. 165 0
      packages/opencode/src/plugin/meta.ts
  55. 149 0
      packages/opencode/src/plugin/shared.ts
  56. 3 3
      packages/opencode/src/provider/auth.ts
  57. 2 1
      packages/opencode/src/session/message-v2.ts
  58. 2 1
      packages/opencode/src/tool/batch.ts
  59. 77 0
      packages/opencode/src/util/error.ts
  60. 333 0
      packages/opencode/src/util/flock.ts
  61. 6 0
      packages/opencode/src/util/network.ts
  62. 2 1
      packages/opencode/src/util/process.ts
  63. 3 0
      packages/opencode/src/util/record.ts
  64. 8 4
      packages/opencode/src/worktree/index.ts
  65. 90 0
      packages/opencode/test/cli/tui/keybind-plugin.test.ts
  66. 61 0
      packages/opencode/test/cli/tui/plugin-add.test.ts
  67. 95 0
      packages/opencode/test/cli/tui/plugin-install.test.ts
  68. 225 0
      packages/opencode/test/cli/tui/plugin-lifecycle.test.ts
  69. 132 0
      packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
  70. 71 0
      packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
  71. 563 0
      packages/opencode/test/cli/tui/plugin-loader.test.ts
  72. 157 0
      packages/opencode/test/cli/tui/plugin-toggle.test.ts
  73. 50 0
      packages/opencode/test/cli/tui/theme-store.test.ts
  74. 165 48
      packages/opencode/test/config/config.test.ts
  75. 159 2
      packages/opencode/test/config/tui.test.ts
  76. 72 0
      packages/opencode/test/fixture/flock-worker.ts
  77. 93 0
      packages/opencode/test/fixture/plug-worker.ts
  78. 26 0
      packages/opencode/test/fixture/plugin-meta-worker.ts
  79. 334 0
      packages/opencode/test/fixture/tui-plugin.ts
  80. 34 0
      packages/opencode/test/fixture/tui-runtime.ts
  81. 12 9
      packages/opencode/test/plugin/auth-override.test.ts
  82. 134 0
      packages/opencode/test/plugin/install-concurrency.test.ts
  83. 410 0
      packages/opencode/test/plugin/install.test.ts
  84. 548 0
      packages/opencode/test/plugin/loader-shared.test.ts
  85. 137 0
      packages/opencode/test/plugin/meta.test.ts
  86. 38 0
      packages/opencode/test/util/error.test.ts
  87. 383 0
      packages/opencode/test/util/flock.test.ts
  88. 16 1
      packages/plugin/package.json
  89. 18 4
      packages/plugin/src/index.ts
  90. 419 0
      packages/plugin/src/tui.ts
  91. 9 1
      packages/sdk/js/src/v2/gen/types.gen.ts

+ 223 - 0
.opencode/plugins/smoke-theme.json

@@ -0,0 +1,223 @@
+{
+  "$schema": "https://opencode.ai/theme.json",
+  "defs": {
+    "nord0": "#2E3440",
+    "nord1": "#3B4252",
+    "nord2": "#434C5E",
+    "nord3": "#4C566A",
+    "nord4": "#D8DEE9",
+    "nord5": "#E5E9F0",
+    "nord6": "#ECEFF4",
+    "nord7": "#8FBCBB",
+    "nord8": "#88C0D0",
+    "nord9": "#81A1C1",
+    "nord10": "#5E81AC",
+    "nord11": "#BF616A",
+    "nord12": "#D08770",
+    "nord13": "#EBCB8B",
+    "nord14": "#A3BE8C",
+    "nord15": "#B48EAD"
+  },
+  "theme": {
+    "primary": {
+      "dark": "nord10",
+      "light": "nord9"
+    },
+    "secondary": {
+      "dark": "nord9",
+      "light": "nord9"
+    },
+    "accent": {
+      "dark": "nord7",
+      "light": "nord7"
+    },
+    "error": {
+      "dark": "nord11",
+      "light": "nord11"
+    },
+    "warning": {
+      "dark": "nord12",
+      "light": "nord12"
+    },
+    "success": {
+      "dark": "nord14",
+      "light": "nord14"
+    },
+    "info": {
+      "dark": "nord8",
+      "light": "nord10"
+    },
+    "text": {
+      "dark": "nord6",
+      "light": "nord0"
+    },
+    "textMuted": {
+      "dark": "#8B95A7",
+      "light": "nord1"
+    },
+    "background": {
+      "dark": "nord0",
+      "light": "nord6"
+    },
+    "backgroundPanel": {
+      "dark": "nord1",
+      "light": "nord5"
+    },
+    "backgroundElement": {
+      "dark": "nord2",
+      "light": "nord4"
+    },
+    "border": {
+      "dark": "nord2",
+      "light": "nord3"
+    },
+    "borderActive": {
+      "dark": "nord3",
+      "light": "nord2"
+    },
+    "borderSubtle": {
+      "dark": "nord2",
+      "light": "nord3"
+    },
+    "diffAdded": {
+      "dark": "nord14",
+      "light": "nord14"
+    },
+    "diffRemoved": {
+      "dark": "nord11",
+      "light": "nord11"
+    },
+    "diffContext": {
+      "dark": "#8B95A7",
+      "light": "nord3"
+    },
+    "diffHunkHeader": {
+      "dark": "#8B95A7",
+      "light": "nord3"
+    },
+    "diffHighlightAdded": {
+      "dark": "nord14",
+      "light": "nord14"
+    },
+    "diffHighlightRemoved": {
+      "dark": "nord11",
+      "light": "nord11"
+    },
+    "diffAddedBg": {
+      "dark": "#36413C",
+      "light": "#E6EBE7"
+    },
+    "diffRemovedBg": {
+      "dark": "#43393D",
+      "light": "#ECE6E8"
+    },
+    "diffContextBg": {
+      "dark": "nord1",
+      "light": "nord5"
+    },
+    "diffLineNumber": {
+      "dark": "nord2",
+      "light": "nord4"
+    },
+    "diffAddedLineNumberBg": {
+      "dark": "#303A35",
+      "light": "#DDE4DF"
+    },
+    "diffRemovedLineNumberBg": {
+      "dark": "#3C3336",
+      "light": "#E4DDE0"
+    },
+    "markdownText": {
+      "dark": "nord4",
+      "light": "nord0"
+    },
+    "markdownHeading": {
+      "dark": "nord8",
+      "light": "nord10"
+    },
+    "markdownLink": {
+      "dark": "nord9",
+      "light": "nord9"
+    },
+    "markdownLinkText": {
+      "dark": "nord7",
+      "light": "nord7"
+    },
+    "markdownCode": {
+      "dark": "nord14",
+      "light": "nord14"
+    },
+    "markdownBlockQuote": {
+      "dark": "#8B95A7",
+      "light": "nord3"
+    },
+    "markdownEmph": {
+      "dark": "nord12",
+      "light": "nord12"
+    },
+    "markdownStrong": {
+      "dark": "nord13",
+      "light": "nord13"
+    },
+    "markdownHorizontalRule": {
+      "dark": "#8B95A7",
+      "light": "nord3"
+    },
+    "markdownListItem": {
+      "dark": "nord8",
+      "light": "nord10"
+    },
+    "markdownListEnumeration": {
+      "dark": "nord7",
+      "light": "nord7"
+    },
+    "markdownImage": {
+      "dark": "nord9",
+      "light": "nord9"
+    },
+    "markdownImageText": {
+      "dark": "nord7",
+      "light": "nord7"
+    },
+    "markdownCodeBlock": {
+      "dark": "nord4",
+      "light": "nord0"
+    },
+    "syntaxComment": {
+      "dark": "#8B95A7",
+      "light": "nord3"
+    },
+    "syntaxKeyword": {
+      "dark": "nord9",
+      "light": "nord9"
+    },
+    "syntaxFunction": {
+      "dark": "nord8",
+      "light": "nord8"
+    },
+    "syntaxVariable": {
+      "dark": "nord7",
+      "light": "nord7"
+    },
+    "syntaxString": {
+      "dark": "nord14",
+      "light": "nord14"
+    },
+    "syntaxNumber": {
+      "dark": "nord15",
+      "light": "nord15"
+    },
+    "syntaxType": {
+      "dark": "nord7",
+      "light": "nord7"
+    },
+    "syntaxOperator": {
+      "dark": "nord9",
+      "light": "nord9"
+    },
+    "syntaxPunctuation": {
+      "dark": "nord4",
+      "light": "nord0"
+    }
+  }
+}

+ 852 - 0
.opencode/plugins/tui-smoke.tsx

@@ -0,0 +1,852 @@
+/** @jsxImportSource @opentui/solid */
+import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { RGBA, VignetteEffect } from "@opentui/core"
+import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
+
+const tabs = ["overview", "counter", "help"]
+const bind = {
+  modal: "ctrl+shift+m",
+  screen: "ctrl+shift+o",
+  home: "escape,ctrl+h",
+  left: "left,h",
+  right: "right,l",
+  up: "up,k",
+  down: "down,j",
+  alert: "a",
+  confirm: "c",
+  prompt: "p",
+  select: "s",
+  modal_accept: "enter,return",
+  modal_close: "escape",
+  dialog_close: "escape",
+  local: "x",
+  local_push: "enter,return",
+  local_close: "q,backspace",
+  host: "z",
+}
+
+const pick = (value: unknown, fallback: string) => {
+  if (typeof value !== "string") return fallback
+  if (!value.trim()) return fallback
+  return value
+}
+
+const num = (value: unknown, fallback: number) => {
+  if (typeof value !== "number") return fallback
+  return value
+}
+
+const rec = (value: unknown) => {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return
+  return Object.fromEntries(Object.entries(value))
+}
+
+type Cfg = {
+  label: string
+  route: string
+  vignette: number
+  keybinds: Record<string, unknown> | undefined
+}
+
+type Route = {
+  modal: string
+  screen: string
+}
+
+type State = {
+  tab: number
+  count: number
+  source: string
+  note: string
+  selected: string
+  local: number
+}
+
+const cfg = (options: Record<string, unknown> | undefined) => {
+  return {
+    label: pick(options?.label, "smoke"),
+    route: pick(options?.route, "workspace-smoke"),
+    vignette: Math.max(0, num(options?.vignette, 0.35)),
+    keybinds: rec(options?.keybinds),
+  }
+}
+
+const names = (input: Cfg) => {
+  return {
+    modal: `${input.route}.modal`,
+    screen: `${input.route}.screen`,
+  }
+}
+
+type Keys = TuiKeybindSet
+const ui = {
+  panel: "#1d1d1d",
+  border: "#4a4a4a",
+  text: "#f0f0f0",
+  muted: "#a5a5a5",
+  accent: "#5f87ff",
+}
+
+type Color = RGBA | string
+
+const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
+  const value = map[name]
+  if (typeof value === "string") return value
+  if (value instanceof RGBA) return value
+  return fallback
+}
+
+const look = (map: Record<string, unknown>) => {
+  return {
+    panel: ink(map, "backgroundPanel", ui.panel),
+    border: ink(map, "border", ui.border),
+    text: ink(map, "text", ui.text),
+    muted: ink(map, "textMuted", ui.muted),
+    accent: ink(map, "primary", ui.accent),
+    selected: ink(map, "selectedListItemText", ui.text),
+  }
+}
+
+const tone = (api: TuiPluginApi) => {
+  return look(api.theme.current)
+}
+
+type Skin = {
+  panel: Color
+  border: Color
+  text: Color
+  muted: Color
+  accent: Color
+  selected: Color
+}
+
+const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
+  return (
+    <box
+      onMouseUp={() => {
+        props.run()
+      }}
+      backgroundColor={props.on ? props.skin.accent : props.skin.border}
+      paddingLeft={1}
+      paddingRight={1}
+    >
+      <text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
+    </box>
+  )
+}
+
+const parse = (params: Record<string, unknown> | undefined) => {
+  const tab = typeof params?.tab === "number" ? params.tab : 0
+  const count = typeof params?.count === "number" ? params.count : 0
+  const source = typeof params?.source === "string" ? params.source : "unknown"
+  const note = typeof params?.note === "string" ? params.note : ""
+  const selected = typeof params?.selected === "string" ? params.selected : ""
+  const local = typeof params?.local === "number" ? params.local : 0
+  return {
+    tab: Math.max(0, Math.min(tab, tabs.length - 1)),
+    count,
+    source,
+    note,
+    selected,
+    local: Math.max(0, local),
+  }
+}
+
+const current = (api: TuiPluginApi, route: Route) => {
+  const value = api.route.current
+  const ok = Object.values(route).includes(value.name)
+  if (!ok) return parse(undefined)
+  if (!("params" in value)) return parse(undefined)
+  return parse(value.params)
+}
+
+const opts = [
+  {
+    title: "Overview",
+    value: 0,
+    description: "Switch to overview tab",
+  },
+  {
+    title: "Counter",
+    value: 1,
+    description: "Switch to counter tab",
+  },
+  {
+    title: "Help",
+    value: 2,
+    description: "Switch to help tab",
+  },
+]
+
+const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
+  api.ui.dialog.setSize("medium")
+  api.ui.dialog.replace(() => (
+    <box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
+      <text fg={skin.text}>
+        <b>{input.label} host overlay</b>
+      </text>
+      <text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
+      <text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
+      <box flexDirection="row" gap={1}>
+        <Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
+      </box>
+    </box>
+  ))
+}
+
+const warn = (api: TuiPluginApi, route: Route, value: State) => {
+  const DialogAlert = api.ui.DialogAlert
+  api.ui.dialog.setSize("medium")
+  api.ui.dialog.replace(() => (
+    <DialogAlert
+      title="Smoke alert"
+      message="Testing built-in alert dialog"
+      onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
+    />
+  ))
+}
+
+const check = (api: TuiPluginApi, route: Route, value: State) => {
+  const DialogConfirm = api.ui.DialogConfirm
+  api.ui.dialog.setSize("medium")
+  api.ui.dialog.replace(() => (
+    <DialogConfirm
+      title="Smoke confirm"
+      message="Apply +1 to counter?"
+      onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
+      onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
+    />
+  ))
+}
+
+const entry = (api: TuiPluginApi, route: Route, value: State) => {
+  const DialogPrompt = api.ui.DialogPrompt
+  api.ui.dialog.setSize("medium")
+  api.ui.dialog.replace(() => (
+    <DialogPrompt
+      title="Smoke prompt"
+      value={value.note}
+      onConfirm={(note) => {
+        api.ui.dialog.clear()
+        api.route.navigate(route.screen, { ...value, note, source: "prompt" })
+      }}
+      onCancel={() => {
+        api.ui.dialog.clear()
+        api.route.navigate(route.screen, value)
+      }}
+    />
+  ))
+}
+
+const picker = (api: TuiPluginApi, route: Route, value: State) => {
+  const DialogSelect = api.ui.DialogSelect
+  api.ui.dialog.setSize("medium")
+  api.ui.dialog.replace(() => (
+    <DialogSelect
+      title="Smoke select"
+      options={opts}
+      current={value.tab}
+      onSelect={(item) => {
+        api.ui.dialog.clear()
+        api.route.navigate(route.screen, {
+          ...value,
+          tab: typeof item.value === "number" ? item.value : value.tab,
+          selected: item.title,
+          source: "select",
+        })
+      }}
+    />
+  ))
+}
+
+const Screen = (props: {
+  api: TuiPluginApi
+  input: Cfg
+  route: Route
+  keys: Keys
+  meta: TuiPluginMeta
+  params?: Record<string, unknown>
+}) => {
+  const dim = useTerminalDimensions()
+  const value = parse(props.params)
+  const skin = tone(props.api)
+  const set = (local: number, base?: State) => {
+    const next = base ?? current(props.api, props.route)
+    props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
+  }
+  const push = (base?: State) => {
+    const next = base ?? current(props.api, props.route)
+    set(next.local + 1, next)
+  }
+  const open = () => {
+    const next = current(props.api, props.route)
+    if (next.local > 0) return
+    set(1, next)
+  }
+  const pop = (base?: State) => {
+    const next = base ?? current(props.api, props.route)
+    const local = Math.max(0, next.local - 1)
+    set(local, next)
+  }
+  const show = () => {
+    setTimeout(() => {
+      open()
+    }, 0)
+  }
+  useKeyboard((evt) => {
+    if (props.api.route.current.name !== props.route.screen) return
+    const next = current(props.api, props.route)
+    if (props.api.ui.dialog.open) {
+      if (props.keys.match("dialog_close", evt)) {
+        evt.preventDefault()
+        evt.stopPropagation()
+        props.api.ui.dialog.clear()
+        return
+      }
+      return
+    }
+
+    if (next.local > 0) {
+      if (evt.name === "escape" || props.keys.match("local_close", evt)) {
+        evt.preventDefault()
+        evt.stopPropagation()
+        pop(next)
+        return
+      }
+
+      if (props.keys.match("local_push", evt)) {
+        evt.preventDefault()
+        evt.stopPropagation()
+        push(next)
+        return
+      }
+      return
+    }
+
+    if (props.keys.match("home", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      props.api.route.navigate("home")
+      return
+    }
+
+    if (props.keys.match("left", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
+      return
+    }
+
+    if (props.keys.match("right", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
+      return
+    }
+
+    if (props.keys.match("up", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
+      return
+    }
+
+    if (props.keys.match("down", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
+      return
+    }
+
+    if (props.keys.match("modal", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      props.api.route.navigate(props.route.modal, next)
+      return
+    }
+
+    if (props.keys.match("local", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      open()
+      return
+    }
+
+    if (props.keys.match("host", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      host(props.api, props.input, skin)
+      return
+    }
+
+    if (props.keys.match("alert", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      warn(props.api, props.route, next)
+      return
+    }
+
+    if (props.keys.match("confirm", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      check(props.api, props.route, next)
+      return
+    }
+
+    if (props.keys.match("prompt", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      entry(props.api, props.route, next)
+      return
+    }
+
+    if (props.keys.match("select", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      picker(props.api, props.route, next)
+    }
+  })
+
+  return (
+    <box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
+      <box
+        flexDirection="column"
+        width="100%"
+        height="100%"
+        paddingTop={1}
+        paddingBottom={1}
+        paddingLeft={2}
+        paddingRight={2}
+      >
+        <box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
+          <text fg={skin.text}>
+            <b>{props.input.label} screen</b>
+            <span style={{ fg: skin.muted }}> plugin route</span>
+          </text>
+          <text fg={skin.muted}>{props.keys.print("home")} home</text>
+        </box>
+
+        <box flexDirection="row" gap={1} paddingBottom={1}>
+          {tabs.map((item, i) => {
+            const on = value.tab === i
+            return (
+              <Btn
+                txt={item}
+                run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
+                skin={skin}
+                on={on}
+              />
+            )
+          })}
+        </box>
+
+        <box
+          border
+          borderColor={skin.border}
+          paddingTop={1}
+          paddingBottom={1}
+          paddingLeft={2}
+          paddingRight={2}
+          flexGrow={1}
+        >
+          {value.tab === 0 ? (
+            <box flexDirection="column" gap={1}>
+              <text fg={skin.text}>Route: {props.route.screen}</text>
+              <text fg={skin.muted}>plugin state: {props.meta.state}</text>
+              <text fg={skin.muted}>
+                first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
+                {props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
+              </text>
+              <text fg={skin.muted}>plugin source: {props.meta.source}</text>
+              <text fg={skin.muted}>source: {value.source}</text>
+              <text fg={skin.muted}>note: {value.note || "(none)"}</text>
+              <text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
+              <text fg={skin.muted}>local stack depth: {value.local}</text>
+              <text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
+            </box>
+          ) : null}
+
+          {value.tab === 1 ? (
+            <box flexDirection="column" gap={1}>
+              <text fg={skin.text}>Counter: {value.count}</text>
+              <text fg={skin.muted}>
+                {props.keys.print("up")} / {props.keys.print("down")} change value
+              </text>
+            </box>
+          ) : null}
+
+          {value.tab === 2 ? (
+            <box flexDirection="column" gap={1}>
+              <text fg={skin.muted}>
+                {props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
+                confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
+              </text>
+              <text fg={skin.muted}>
+                {props.keys.print("local")} local stack | {props.keys.print("host")} host stack
+              </text>
+              <text fg={skin.muted}>
+                local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
+                close
+              </text>
+              <text fg={skin.muted}>{props.keys.print("home")} returns home</text>
+            </box>
+          ) : null}
+        </box>
+
+        <box flexDirection="row" gap={1} paddingTop={1}>
+          <Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
+          <Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
+          <Btn txt="local overlay" run={show} skin={skin} />
+          <Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
+          <Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
+          <Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
+          <Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
+          <Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
+        </box>
+      </box>
+
+      <box
+        visible={value.local > 0}
+        width={dim().width}
+        height={dim().height}
+        alignItems="center"
+        position="absolute"
+        zIndex={3000}
+        paddingTop={dim().height / 4}
+        left={0}
+        top={0}
+        backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
+        onMouseUp={() => {
+          pop()
+        }}
+      >
+        <box
+          onMouseUp={(evt) => {
+            evt.stopPropagation()
+          }}
+          width={60}
+          maxWidth={dim().width - 2}
+          backgroundColor={skin.panel}
+          border
+          borderColor={skin.border}
+          paddingTop={1}
+          paddingBottom={1}
+          paddingLeft={2}
+          paddingRight={2}
+          gap={1}
+          flexDirection="column"
+        >
+          <text fg={skin.text}>
+            <b>{props.input.label} local overlay</b>
+          </text>
+          <text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
+          <text fg={skin.muted}>
+            {props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
+          </text>
+          <box flexDirection="row" gap={1}>
+            <Btn txt="push" run={push} skin={skin} on />
+            <Btn txt="pop" run={pop} skin={skin} />
+          </box>
+        </box>
+      </box>
+    </box>
+  )
+}
+
+const Modal = (props: {
+  api: TuiPluginApi
+  input: Cfg
+  route: Route
+  keys: Keys
+  params?: Record<string, unknown>
+}) => {
+  const Dialog = props.api.ui.Dialog
+  const value = parse(props.params)
+  const skin = tone(props.api)
+
+  useKeyboard((evt) => {
+    if (props.api.route.current.name !== props.route.modal) return
+
+    if (props.keys.match("modal_accept", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
+      return
+    }
+
+    if (props.keys.match("modal_close", evt)) {
+      evt.preventDefault()
+      evt.stopPropagation()
+      props.api.route.navigate("home")
+    }
+  })
+
+  return (
+    <box width="100%" height="100%" backgroundColor={skin.panel}>
+      <Dialog onClose={() => props.api.route.navigate("home")}>
+        <box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
+          <text fg={skin.text}>
+            <b>{props.input.label} modal</b>
+          </text>
+          <text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
+          <text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
+          <text fg={skin.muted}>
+            {props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
+          </text>
+          <box flexDirection="row" gap={1}>
+            <Btn
+              txt="open screen"
+              run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
+              skin={skin}
+              on
+            />
+            <Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
+          </box>
+        </box>
+      </Dialog>
+    </box>
+  )
+}
+
+const home = (input: Cfg): TuiSlotPlugin => ({
+  slots: {
+    home_logo(ctx) {
+      const map = ctx.theme.current
+      const skin = look(map)
+      const art = [
+        "                                  $$\\",
+        "                                  $$ |",
+        " $$$$$$$\\ $$$$$$\\$$$$\\   $$$$$$\\  $$ |  $$\\  $$$$$$\\",
+        "$$  _____|$$  _$$  _$$\\ $$  __$$\\ $$ | $$  |$$  __$$\\",
+        "\\$$$$$$\\  $$ / $$ / $$ |$$ /  $$ |$$$$$$  / $$$$$$$$ |",
+        " \\____$$\\ $$ | $$ | $$ |$$ |  $$ |$$  _$$<  $$   ____|",
+        "$$$$$$$  |$$ | $$ | $$ |\\$$$$$$  |$$ | \\$$\\ \\$$$$$$$\\",
+        "\\_______/ \\__| \\__| \\__| \\______/ \\__|  \\__| \\_______|",
+      ]
+      const fill = [
+        skin.accent,
+        skin.muted,
+        ink(map, "info", ui.accent),
+        skin.text,
+        ink(map, "success", ui.accent),
+        ink(map, "warning", ui.accent),
+        ink(map, "secondary", ui.accent),
+        ink(map, "error", ui.accent),
+      ]
+
+      return (
+        <box flexDirection="column">
+          {art.map((line, i) => (
+            <text fg={fill[i]}>{line}</text>
+          ))}
+        </box>
+      )
+    },
+    home_bottom(ctx) {
+      const skin = look(ctx.theme.current)
+      const text = "extra content in the unified home bottom slot"
+
+      return (
+        <box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
+          <box
+            border
+            borderColor={skin.border}
+            backgroundColor={skin.panel}
+            paddingTop={1}
+            paddingBottom={1}
+            paddingLeft={2}
+            paddingRight={2}
+            width="100%"
+          >
+            <text fg={skin.muted}>
+              <span style={{ fg: skin.accent }}>{input.label}</span> {text}
+            </text>
+          </box>
+        </box>
+      )
+    },
+  },
+})
+
+const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
+  order,
+  slots: {
+    sidebar_content(ctx, value) {
+      const skin = look(ctx.theme.current)
+
+      return (
+        <box
+          border
+          borderColor={skin.border}
+          backgroundColor={skin.panel}
+          paddingTop={1}
+          paddingBottom={1}
+          paddingLeft={2}
+          paddingRight={2}
+          flexDirection="column"
+          gap={1}
+        >
+          <text fg={skin.accent}>
+            <b>{title}</b>
+          </text>
+          <text fg={skin.text}>{text}</text>
+          <text fg={skin.muted}>
+            {input.label} order {order} · session {value.session_id.slice(0, 8)}
+          </text>
+        </box>
+      )
+    },
+  },
+})
+
+const slot = (input: Cfg): TuiSlotPlugin[] => [
+  home(input),
+  block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
+  block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
+  block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
+]
+
+const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
+  const route = names(input)
+  api.command.register(() => [
+    {
+      title: `${input.label} modal`,
+      value: "plugin.smoke.modal",
+      keybind: keys.get("modal"),
+      category: "Plugin",
+      slash: {
+        name: "smoke",
+      },
+      onSelect: () => {
+        api.route.navigate(route.modal, { source: "command" })
+      },
+    },
+    {
+      title: `${input.label} screen`,
+      value: "plugin.smoke.screen",
+      keybind: keys.get("screen"),
+      category: "Plugin",
+      slash: {
+        name: "smoke-screen",
+      },
+      onSelect: () => {
+        api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
+      },
+    },
+    {
+      title: `${input.label} alert dialog`,
+      value: "plugin.smoke.alert",
+      category: "Plugin",
+      slash: {
+        name: "smoke-alert",
+      },
+      onSelect: () => {
+        warn(api, route, current(api, route))
+      },
+    },
+    {
+      title: `${input.label} confirm dialog`,
+      value: "plugin.smoke.confirm",
+      category: "Plugin",
+      slash: {
+        name: "smoke-confirm",
+      },
+      onSelect: () => {
+        check(api, route, current(api, route))
+      },
+    },
+    {
+      title: `${input.label} prompt dialog`,
+      value: "plugin.smoke.prompt",
+      category: "Plugin",
+      slash: {
+        name: "smoke-prompt",
+      },
+      onSelect: () => {
+        entry(api, route, current(api, route))
+      },
+    },
+    {
+      title: `${input.label} select dialog`,
+      value: "plugin.smoke.select",
+      category: "Plugin",
+      slash: {
+        name: "smoke-select",
+      },
+      onSelect: () => {
+        picker(api, route, current(api, route))
+      },
+    },
+    {
+      title: `${input.label} host overlay`,
+      value: "plugin.smoke.host",
+      category: "Plugin",
+      slash: {
+        name: "smoke-host",
+      },
+      onSelect: () => {
+        host(api, input, tone(api))
+      },
+    },
+    {
+      title: `${input.label} go home`,
+      value: "plugin.smoke.home",
+      category: "Plugin",
+      enabled: api.route.current.name !== "home",
+      onSelect: () => {
+        api.route.navigate("home")
+      },
+    },
+    {
+      title: `${input.label} toast`,
+      value: "plugin.smoke.toast",
+      category: "Plugin",
+      onSelect: () => {
+        api.ui.toast({
+          variant: "info",
+          title: "Smoke",
+          message: "Plugin toast works",
+          duration: 2000,
+        })
+      },
+    },
+  ])
+}
+
+const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => {
+  if (options?.enabled === false) return
+
+  await api.theme.install("./smoke-theme.json")
+  api.theme.set("smoke-theme")
+
+  const value = cfg(options ?? undefined)
+  const route = names(value)
+  const keys = api.keybind.create(bind, value.keybinds)
+  const fx = new VignetteEffect(value.vignette)
+  const post = fx.apply.bind(fx)
+  api.renderer.addPostProcessFn(post)
+  api.lifecycle.onDispose(() => {
+    api.renderer.removePostProcessFn(post)
+  })
+
+  api.route.register([
+    {
+      name: route.screen,
+      render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
+    },
+    {
+      name: route.modal,
+      render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
+    },
+  ])
+
+  reg(api, value, keys)
+  for (const item of slot(value)) {
+    api.slots.register(item)
+  }
+}
+
+export default {
+  id: "tui-smoke",
+  tui,
+}

+ 1 - 0
.opencode/themes/.gitignore

@@ -0,0 +1 @@
+smoke-theme.json

+ 19 - 0
.opencode/tui.json

@@ -0,0 +1,19 @@
+{
+  "$schema": "https://opencode.ai/tui.json",
+  "theme": "smoke-theme",
+  "plugin": [
+    [
+      "./plugins/tui-smoke.tsx",
+      {
+        "enabled": false,
+        "label": "workspace",
+        "keybinds": {
+          "modal": "ctrl+alt+m",
+          "screen": "ctrl+alt+o",
+          "home": "escape,ctrl+shift+h",
+          "dialog_close": "escape,q"
+        }
+      }
+    ]
+  ]
+}

+ 13 - 3
bun.lock

@@ -428,11 +428,21 @@
         "zod": "catalog:",
       },
       "devDependencies": {
+        "@opentui/core": "0.1.90",
+        "@opentui/solid": "0.1.90",
         "@tsconfig/node22": "catalog:",
         "@types/node": "catalog:",
         "@typescript/native-preview": "catalog:",
         "typescript": "catalog:",
       },
+      "peerDependencies": {
+        "@opentui/core": ">=0.1.90",
+        "@opentui/solid": ">=0.1.90",
+      },
+      "optionalPeers": [
+        "@opentui/core",
+        "@opentui/solid",
+      ],
     },
     "packages/script": {
       "name": "@opencode-ai/script",
@@ -3837,7 +3847,7 @@
 
     "pagefind": ["pagefind@1.4.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.4.0", "@pagefind/darwin-x64": "1.4.0", "@pagefind/freebsd-x64": "1.4.0", "@pagefind/linux-arm64": "1.4.0", "@pagefind/linux-x64": "1.4.0", "@pagefind/windows-x64": "1.4.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g=="],
 
-    "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
+    "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
 
     "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
 
@@ -5677,12 +5687,12 @@
 
     "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
 
+    "unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
+
     "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
 
     "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
 
-    "utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
-
     "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
 
     "vitest/@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],

+ 3 - 1
packages/app/src/components/status-popover-body.tsx

@@ -239,7 +239,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
   const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
   const lspItems = createMemo(() => sync.data.lsp ?? [])
   const lspCount = createMemo(() => lspItems().length)
-  const plugins = createMemo(() => sync.data.config.plugin ?? [])
+  const plugins = createMemo(() =>
+    (sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
+  )
   const pluginCount = createMemo(() => plugins().length)
   const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
 

+ 1 - 1
packages/opencode/bunfig.toml

@@ -1,7 +1,7 @@
 preload = ["@opentui/solid/preload"]
 
 [test]
-preload = ["./test/preload.ts"]
+preload = ["@opentui/solid/preload", "./test/preload.ts"]
 # timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
 # using --timeout in package.json scripts instead
 # https://github.com/oven-sh/bun/issues/7789

+ 3 - 2
packages/opencode/script/build.ts

@@ -4,7 +4,7 @@ import { $ } from "bun"
 import fs from "fs"
 import path from "path"
 import { fileURLToPath } from "url"
-import solidPlugin from "@opentui/solid/bun-plugin"
+import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
 
 const __filename = fileURLToPath(import.meta.url)
 const __dirname = path.dirname(__filename)
@@ -63,6 +63,7 @@ console.log(`Loaded ${migrations.length} migrations`)
 const singleFlag = process.argv.includes("--single")
 const baselineFlag = process.argv.includes("--baseline")
 const skipInstall = process.argv.includes("--skip-install")
+const plugin = createSolidTransformPlugin()
 const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
 
 const createEmbeddedWebUIBundle = async () => {
@@ -207,7 +208,7 @@ for (const item of targets) {
   await Bun.build({
     conditions: ["browser"],
     tsconfig: "./tsconfig.json",
-    plugins: [solidPlugin],
+    plugins: [plugin],
     compile: {
       autoloadBunfig: false,
       autoloadDotenv: false,

+ 377 - 0
packages/opencode/specs/tui-plugins.md

@@ -0,0 +1,377 @@
+# TUI plugins
+
+Technical reference for the current TUI plugin system.
+
+## Overview
+
+- TUI plugin config lives in `tui.json`.
+- Author package entrypoint is `@opencode-ai/plugin/tui`.
+- Internal plugins load inside the CLI app the same way external TUI plugins do.
+- Package plugins can be installed from CLI or TUI.
+
+## TUI config
+
+Example:
+
+```json
+{
+  "$schema": "https://opencode.ai/tui.json",
+  "theme": "smoke-theme",
+  "plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
+  "plugin_enabled": {
+    "acme.demo": false
+  }
+}
+```
+
+- `plugin` entries can be either a string spec or `[spec, options]`.
+- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
+- Relative path specs are resolved relative to the config file that declared them.
+- Duplicate npm plugins are deduped by package name; higher-precedence config wins.
+- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
+- `plugin_enabled` is keyed by plugin id, not by plugin spec.
+- For file plugins, that id must come from the plugin module's exported `id`. For npm plugins, it is the exported `id` or the package name if `id` is omitted.
+- Plugins are enabled by default. `plugin_enabled` is only for explicit overrides, usually to disable a plugin with `false`.
+- `plugin_enabled` is merged across config layers.
+- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
+
+## Author package shape
+
+Package entrypoint:
+
+- Import types from `@opencode-ai/plugin/tui`.
+- `@opencode-ai/plugin` exports `./tui` and declares optional peer deps on `@opentui/core` and `@opentui/solid`.
+
+Minimal module shape:
+
+```tsx
+/** @jsxImportSource @opentui/solid */
+import type { TuiPlugin } from "@opencode-ai/plugin/tui"
+
+const tui: TuiPlugin = async (api, options, meta) => {
+  api.command.register(() => [
+    {
+      title: "Demo",
+      value: "demo.open",
+      onSelect: () => api.route.navigate("demo"),
+    },
+  ])
+
+  api.route.register([
+    {
+      name: "demo",
+      render: () => (
+        <box>
+          <text>demo</text>
+        </box>
+      ),
+    },
+  ])
+}
+
+export default {
+  id: "acme.demo",
+  tui,
+}
+```
+
+- Loader only reads the module default export object. Named exports are ignored.
+- TUI shape is `default export { id?, tui }`.
+- `tui` signature is `(api, options, meta) => Promise<void>`.
+- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
+- File/path plugins must export a non-empty `id`.
+- npm plugins may omit `id`; package `name` is used.
+- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
+- If a path spec points at a directory, that directory must have `package.json` with `main`.
+- There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
+
+## Package manifest and install
+
+Package manifest is read from `package.json` field `oc-plugin`.
+
+Example:
+
+```json
+{
+  "name": "@acme/opencode-plugin",
+  "type": "module",
+  "main": "./dist/index.js",
+  "engines": {
+    "opencode": "^1.0.0"
+  },
+  "oc-plugin": [
+    ["server", { "custom": true }],
+    ["tui", { "compact": true }]
+  ]
+}
+```
+
+### Version compatibility
+
+npm plugins can declare a version compatibility range in `package.json` using the standard `engines` field:
+
+```json
+{
+  "engines": {
+    "opencode": "^1.0.0"
+  }
+}
+```
+
+- The value is a semver range checked against the running OpenCode version.
+- If the range is not satisfied, the plugin is skipped with a warning and a session error.
+- If `engines.opencode` is absent, no check is performed (backward compatible).
+- File plugins are never checked; only npm package plugins are validated.
+
+- Install flow is shared by CLI and TUI in `src/plugin/install.ts`.
+- Shared helpers are `installPlugin`, `readPluginManifest`, and `patchPluginConfig`.
+- `opencode plugin <module>` and TUI install both run install → manifest read → config patch.
+- Alias: `opencode plug <module>`.
+- `-g` / `--global` writes into the global config dir.
+- Local installs resolve target dir inside `patchPluginConfig`.
+- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
+- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
+- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
+- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
+- Without `--force`, an already-configured npm package name is a no-op.
+- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
+- Tuple targets in `oc-plugin` provide default options written into config.
+- A package can target `server`, `tui`, or both.
+- There is no uninstall, list, or update CLI command for external plugins.
+- Local file plugins are configured directly in `tui.json`.
+
+When `plugin` entries exist in a writable `.opencode` dir or `OPENCODE_CONFIG_DIR`, OpenCode installs `@opencode-ai/plugin` into that dir and writes:
+
+- `package.json`
+- `bun.lock`
+- `node_modules/`
+- `.gitignore`
+
+That is what makes local config-scoped plugins able to import `@opencode-ai/plugin/tui`.
+
+## TUI plugin API
+
+Top-level API groups exposed to `tui(api, options, meta)`:
+
+- `api.app.version`
+- `api.command.register(cb)` / `api.command.trigger(value)`
+- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
+- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog`
+- `api.keybind.match`, `print`, `create`
+- `api.tuiConfig`
+- `api.kv.get`, `set`, `ready`
+- `api.state`
+- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
+- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
+- `api.event.on(type, handler)`
+- `api.renderer`
+- `api.slots.register(plugin)`
+- `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)`
+- `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)`
+
+### Commands
+
+`api.command.register` returns an unregister function. Command rows support:
+
+- `title`, `value`
+- `description`, `category`
+- `keybind`
+- `suggested`, `hidden`, `enabled`
+- `slash: { name, aliases? }`
+- `onSelect`
+
+Command behavior:
+
+- Registrations are reactive.
+- Later registrations win for duplicate `value` and for keybind handling.
+- Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and `command.trigger(value)` if `enabled !== false`.
+
+### Routes
+
+- Reserved route names: `home` and `session`.
+- Any other name is treated as a plugin route.
+- `api.route.current` returns one of:
+  - `{ name: "home" }`
+  - `{ name: "session", params: { sessionID, initialPrompt? } }`
+  - `{ name: string, params?: Record<string, unknown> }`
+- `api.route.navigate("session", params)` only uses `params.sessionID`. It cannot set `initialPrompt`.
+- If multiple plugins register the same route name, the last registered route wins.
+- Unknown plugin routes render a fallback screen with a `go home` action.
+
+### Dialogs and toast
+
+- `ui.Dialog` is the base dialog wrapper.
+- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
+- `ui.toast(...)` shows a toast.
+- `ui.dialog` exposes the host dialog stack:
+  - `replace(render, onClose?)`
+  - `clear()`
+  - `setSize("medium" | "large" | "xlarge")`
+  - readonly `size`, `depth`, `open`
+
+### Keybinds
+
+- `api.keybind.match(key, evt)` and `print(key)` use the host keybind parser/printer.
+- `api.keybind.create(defaults, overrides?)` builds a plugin-local keybind set.
+- Only missing, blank, or non-string overrides are ignored. Key syntax is not validated.
+- Returned keybind set exposes `all`, `get(name)`, `match(name, evt)`, `print(name)`.
+
+### KV, state, client, events
+
+- `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced.
+- `api.kv` exposes `ready`.
+- `api.tuiConfig` and `api.state` are live host objects/getters, not frozen snapshots.
+- `api.state` exposes synced TUI state:
+  - `ready`
+  - `config`
+  - `provider`
+  - `path.{state,config,worktree,directory}`
+  - `vcs?.branch`
+  - `workspace.list()` / `workspace.get(workspaceID)`
+  - `session.count()`
+  - `session.diff(sessionID)`
+  - `session.todo(sessionID)`
+  - `session.messages(sessionID)`
+  - `session.status(sessionID)`
+  - `session.permission(sessionID)`
+  - `session.question(sessionID)`
+  - `part(messageID)`
+  - `lsp()`
+  - `mcp()`
+- `api.client` always reflects the current runtime client.
+- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
+- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
+- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
+- `api.renderer` exposes the raw `CliRenderer`.
+
+### Theme
+
+- `api.theme.current` exposes the resolved current theme tokens.
+- `api.theme.selected` is the selected theme name.
+- `api.theme.has(name)` checks for an installed theme.
+- `api.theme.set(name)` switches theme and returns `boolean`.
+- `api.theme.mode()` returns `"dark" | "light"`.
+- `api.theme.install(jsonPath)` installs a theme JSON file.
+- `api.theme.ready` reports theme readiness.
+
+Theme install behavior:
+
+- Relative theme paths are resolved from the plugin root.
+- Theme name is the JSON basename.
+- Install is skipped if that theme name already exists.
+- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
+- Global plugins persist installed themes under the global `themes` dir.
+- Invalid or unreadable theme files are ignored.
+
+### Slots
+
+Current host slot names:
+
+- `app`
+- `home_logo`
+- `home_bottom`
+- `sidebar_title` with props `{ session_id, title, share_url? }`
+- `sidebar_content` with props `{ session_id }`
+- `sidebar_footer` with props `{ session_id }`
+
+Slot notes:
+
+- Slot context currently exposes only `theme`.
+- `api.slots.register(plugin)` returns the host-assigned slot plugin id.
+- `api.slots.register(plugin)` does not return an unregister function.
+- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
+- Plugin-provided `id` is not allowed.
+- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
+- Plugins cannot define new slot names in this branch.
+
+### Plugin control and lifecycle
+
+- `api.plugins.list()` returns `{ id, source, spec, target, enabled, active }[]`.
+- `enabled` is the persisted desired state. `active` means the plugin is currently initialized.
+- `api.plugins.activate(id)` sets `enabled=true`, persists it into KV, and initializes the plugin.
+- `api.plugins.deactivate(id)` sets `enabled=false`, persists it into KV, and disposes the plugin scope.
+- `api.plugins.add(spec)` trims the input and returns `false` for an empty string.
+- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
+- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
+- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
+- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
+- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
+- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
+- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
+- If activation fails, the plugin can remain `enabled=true` and `active=false`.
+- `api.lifecycle.signal` is aborted before cleanup runs.
+- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
+
+## Plugin metadata
+
+`meta` passed to `tui(api, options, meta)` contains:
+
+- `state`: `first | updated | same`
+- `id`, `source`, `spec`, `target`
+- npm-only fields when available: `requested`, `version`
+- file-only field when available: `modified`
+- `first_time`, `last_time`, `time_changed`, `load_count`, `fingerprint`
+
+Metadata is persisted by plugin id.
+
+- File plugin fingerprint is `target|modified`.
+- npm plugin fingerprint is `target|requested|version`.
+- Internal plugins get synthetic metadata with `state: "same"`.
+
+## Runtime behavior
+
+- Internal TUI plugins load first.
+- External TUI plugins load from `tuiConfig.plugin`.
+- `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
+- External plugin resolution and import are parallel.
+- External plugin activation is sequential to keep command, route, and side-effect order deterministic.
+- File plugins that fail initially are retried once after waiting for config dependency installation.
+- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
+- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
+- Runtime install and runtime add are separate operations.
+- Plugin init failure rolls back that plugin's tracked registrations and loading continues.
+- TUI runtime tracks and disposes:
+  - command registrations
+  - route registrations
+  - event subscriptions
+  - slot registrations
+  - explicit `lifecycle.onDispose(...)` handlers
+- Cleanup runs in reverse order.
+- Cleanup is awaited.
+- Total cleanup budget per plugin is 5 seconds; timeout/error is logged and shutdown continues.
+
+## Built-in plugins
+
+- `internal:home-tips`
+- `internal:sidebar-context`
+- `internal:sidebar-mcp`
+- `internal:sidebar-lsp`
+- `internal:sidebar-todo`
+- `internal:sidebar-files`
+- `internal:sidebar-footer`
+- `internal:plugin-manager`
+
+Sidebar content order is currently: context `100`, mcp `200`, lsp `300`, todo `400`, files `500`.
+
+The plugin manager is exposed as a command with title `Plugins` and value `plugins.list`.
+
+- Keybind name is `plugin_manager`.
+- Default keybind is `none`.
+- It lists both internal and external plugins.
+- It toggles based on `active`.
+- Its own row is disabled only inside the manager dialog.
+- It also exposes command `plugins.install` with title `Install plugin`.
+- Inside the Plugins dialog, key `shift+i` opens the install prompt.
+- Install prompt asks for npm package name.
+- Scope defaults to local, and `tab` toggles local/global.
+- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
+- Manager install uses `api.plugins.install(spec, { global })`.
+- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
+- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
+- If runtime add fails, TUI shows a warning and restart remains the fallback.
+
+## Current in-repo examples
+
+- Local smoke plugin: `.opencode/plugins/tui-smoke.tsx`
+- Local smoke config: `.opencode/tui.json`
+- Local smoke theme: `.opencode/plugins/smoke-theme.json`

+ 6 - 5
packages/opencode/src/bun/index.ts

@@ -6,7 +6,7 @@ import { Filesystem } from "../util/filesystem"
 import { NamedError } from "@opencode-ai/util/error"
 import { Lock } from "../util/lock"
 import { PackageRegistry } from "./registry"
-import { proxied } from "@/util/proxied"
+import { online, proxied } from "@/util/network"
 import { Process } from "../util/process"
 
 export namespace BunProc {
@@ -68,12 +68,13 @@ export namespace BunProc {
 
     if (!modExists || !cachedVersion) {
       // continue to install
-    } else if (version !== "latest" && cachedVersion === version) {
-      return mod
     } else if (version === "latest") {
-      const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
-      if (!isOutdated) return mod
+      if (!online()) return mod
+      const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
+      if (!stale) return mod
       log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
+    } else if (cachedVersion === version) {
+      return mod
     }
 
     // Build command arguments

+ 6 - 0
packages/opencode/src/bun/registry.ts

@@ -1,6 +1,7 @@
 import semver from "semver"
 import { Log } from "../util/log"
 import { Process } from "../util/process"
+import { online } from "@/util/network"
 
 export namespace PackageRegistry {
   const log = Log.create({ service: "bun" })
@@ -10,6 +11,11 @@ export namespace PackageRegistry {
   }
 
   export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
+    if (!online()) {
+      log.debug("offline, skipping bun info", { pkg, field })
+      return null
+    }
+
     const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
       cwd,
       env: {

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

@@ -6,6 +6,7 @@ import { UI } from "../ui"
 import { cmd } from "./cmd"
 import { JsonMigration } from "../../storage/json-migration"
 import { EOL } from "os"
+import { errorMessage } from "../../util/error"
 
 const QueryCommand = cmd({
   command: "$0 [query]",
@@ -39,7 +40,7 @@ const QueryCommand = cmd({
           }
         }
       } catch (err) {
-        UI.error(err instanceof Error ? err.message : String(err))
+        UI.error(errorMessage(err))
         process.exit(1)
       }
       db.close()
@@ -100,7 +101,7 @@ const MigrateCommand = cmd({
       }
     } catch (err) {
       if (tty) process.stderr.write("\x1b[?25h")
-      UI.error(`Migration failed: ${err instanceof Error ? err.message : String(err)}`)
+      UI.error(`Migration failed: ${errorMessage(err)}`)
       process.exit(1)
     } finally {
       sqlite.close()

+ 231 - 0
packages/opencode/src/cli/cmd/plug.ts

@@ -0,0 +1,231 @@
+import { intro, log, outro, spinner } from "@clack/prompts"
+import type { Argv } from "yargs"
+
+import { ConfigPaths } from "../../config/paths"
+import { Global } from "../../global"
+import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
+import { resolvePluginTarget } from "../../plugin/shared"
+import { Instance } from "../../project/instance"
+import { errorMessage } from "../../util/error"
+import { Filesystem } from "../../util/filesystem"
+import { Process } from "../../util/process"
+import { UI } from "../ui"
+import { cmd } from "./cmd"
+
+type Spin = {
+  start: (msg: string) => void
+  stop: (msg: string, code?: number) => void
+}
+
+export type PlugDeps = {
+  spinner: () => Spin
+  log: {
+    error: (msg: string) => void
+    info: (msg: string) => void
+    success: (msg: string) => void
+  }
+  resolve: (spec: string) => Promise<string>
+  readText: (file: string) => Promise<string>
+  write: (file: string, text: string) => Promise<void>
+  exists: (file: string) => Promise<boolean>
+  files: (dir: string, name: "opencode" | "tui") => string[]
+  global: string
+}
+
+export type PlugInput = {
+  mod: string
+  global?: boolean
+  force?: boolean
+}
+
+export type PlugCtx = {
+  vcs?: string
+  worktree: string
+  directory: string
+}
+
+const defaultPlugDeps: PlugDeps = {
+  spinner: () => spinner(),
+  log: {
+    error: (msg) => log.error(msg),
+    info: (msg) => log.info(msg),
+    success: (msg) => log.success(msg),
+  },
+  resolve: (spec) => resolvePluginTarget(spec),
+  readText: (file) => Filesystem.readText(file),
+  write: async (file, text) => {
+    await Filesystem.write(file, text)
+  },
+  exists: (file) => Filesystem.exists(file),
+  files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
+  global: Global.Path.config,
+}
+
+function cause(err: unknown) {
+  if (!err || typeof err !== "object") return
+  if (!("cause" in err)) return
+  return (err as { cause?: unknown }).cause
+}
+
+export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps) {
+  const mod = input.mod
+  const force = Boolean(input.force)
+  const global = Boolean(input.global)
+
+  return async (ctx: PlugCtx) => {
+    const install = dep.spinner()
+    install.start("Installing plugin package...")
+    const target = await installPlugin(mod, dep)
+    if (!target.ok) {
+      install.stop("Install failed", 1)
+      dep.log.error(`Could not install "${mod}"`)
+      const hit = cause(target.error) ?? target.error
+      if (hit instanceof Process.RunFailedError) {
+        const lines = hit.stderr
+          .toString()
+          .split(/\r?\n/)
+          .map((line) => line.trim())
+          .filter(Boolean)
+        const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
+        const detail = errs[0] ?? lines.at(-1)
+        if (detail) dep.log.error(detail)
+        if (lines.some((line) => line.includes("No version matching"))) {
+          dep.log.info("This package depends on a version that is not available in your npm registry.")
+          dep.log.info("Check npm registry/auth settings and try again.")
+        }
+      }
+      if (!(hit instanceof Process.RunFailedError)) {
+        dep.log.error(errorMessage(hit))
+      }
+      return false
+    }
+    install.stop("Plugin package ready")
+
+    const inspect = dep.spinner()
+    inspect.start("Reading plugin manifest...")
+    const manifest = await readPluginManifest(target.target)
+    if (!manifest.ok) {
+      if (manifest.code === "manifest_read_failed") {
+        inspect.stop("Manifest read failed", 1)
+        dep.log.error(`Installed "${mod}" but failed to read ${manifest.file}`)
+        dep.log.error(errorMessage(cause(manifest.error) ?? manifest.error))
+        return false
+      }
+
+      if (manifest.code === "manifest_no_targets") {
+        inspect.stop("No plugin targets found", 1)
+        dep.log.error(`"${mod}" does not declare supported targets in package.json`)
+        dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].')
+        return false
+      }
+
+      inspect.stop("Manifest read failed", 1)
+      return false
+    }
+
+    inspect.stop(
+      `Detected ${manifest.targets.map((item) => item.kind).join(" + ")} target${manifest.targets.length === 1 ? "" : "s"}`,
+    )
+
+    const patch = dep.spinner()
+    patch.start("Updating plugin config...")
+    const out = await patchPluginConfig(
+      {
+        spec: mod,
+        targets: manifest.targets,
+        force,
+        global,
+        vcs: ctx.vcs,
+        worktree: ctx.worktree,
+        directory: ctx.directory,
+        config: dep.global,
+      },
+      dep,
+    )
+    if (!out.ok) {
+      if (out.code === "invalid_json") {
+        patch.stop(`Failed updating ${out.kind} config`, 1)
+        dep.log.error(`Invalid JSON in ${out.file} (${out.parse} at line ${out.line}, column ${out.col})`)
+        dep.log.info("Fix the config file and run the command again.")
+        return false
+      }
+
+      patch.stop("Failed updating plugin config", 1)
+      dep.log.error(errorMessage(out.error))
+      return false
+    }
+    patch.stop("Plugin config updated")
+    for (const item of out.items) {
+      if (item.mode === "noop") {
+        dep.log.info(`Already configured in ${item.file}`)
+        continue
+      }
+      if (item.mode === "replace") {
+        dep.log.info(`Replaced in ${item.file}`)
+        continue
+      }
+      dep.log.info(`Added to ${item.file}`)
+    }
+
+    dep.log.success(`Installed ${mod}`)
+    dep.log.info(global ? `Scope: global (${out.dir})` : `Scope: local (${out.dir})`)
+    return true
+  }
+}
+
+export const PluginCommand = cmd({
+  command: "plugin <module>",
+  aliases: ["plug"],
+  describe: "install plugin and update config",
+  builder: (yargs: Argv) => {
+    return yargs
+      .positional("module", {
+        type: "string",
+        describe: "npm module name",
+      })
+      .option("global", {
+        alias: ["g"],
+        type: "boolean",
+        default: false,
+        describe: "install in global config",
+      })
+      .option("force", {
+        alias: ["f"],
+        type: "boolean",
+        default: false,
+        describe: "replace existing plugin version",
+      })
+  },
+  handler: async (args) => {
+    const mod = String(args.module ?? "").trim()
+    if (!mod) {
+      UI.error("module is required")
+      process.exitCode = 1
+      return
+    }
+
+    UI.empty()
+    intro(`Install plugin ${mod}`)
+
+    const run = createPlugTask({
+      mod,
+      global: Boolean(args.global),
+      force: Boolean(args.force),
+    })
+    let ok = true
+
+    await Instance.provide({
+      directory: process.cwd(),
+      fn: async () => {
+        ok = await run({
+          vcs: Instance.project.vcs,
+          worktree: Instance.worktree,
+          directory: Instance.directory,
+        })
+      },
+    })
+
+    outro("Done")
+    if (!ok) process.exitCode = 1
+  },
+})

+ 183 - 182
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -1,15 +1,30 @@
-import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
+import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
 import { Clipboard } from "@tui/util/clipboard"
 import { Selection } from "@tui/util/selection"
-import { MouseButton, TextAttributes } from "@opentui/core"
+import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
 import { RouteProvider, useRoute } from "@tui/context/route"
-import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
-import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
+import {
+  Switch,
+  Match,
+  createEffect,
+  createMemo,
+  ErrorBoundary,
+  createSignal,
+  onMount,
+  batch,
+  Show,
+  on,
+  onCleanup,
+} from "solid-js"
+import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
 import { Flag } from "@/flag/flag"
 import semver from "semver"
 import { DialogProvider, useDialog } from "@tui/ui/dialog"
 import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
+import { ErrorComponent } from "@tui/component/error-component"
+import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
 import { SDKProvider, useSDK } from "@tui/context/sdk"
+import { StartupLoading } from "@tui/component/startup-loading"
 import { SyncProvider, useSync } from "@tui/context/sync"
 import { LocalProvider, useLocal } from "@tui/context/local"
 import { DialogModel, useConnected } from "@tui/component/dialog-model"
@@ -21,7 +36,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
 import { DialogAgent } from "@tui/component/dialog-agent"
 import { DialogSessionList } from "@tui/component/dialog-session-list"
 import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
-import { KeybindProvider } from "@tui/context/keybind"
+import { KeybindProvider, useKeybind } from "@tui/context/keybind"
 import { ThemeProvider, useTheme } from "@tui/context/theme"
 import { Home } from "@tui/routes/home"
 import { Session } from "@tui/routes/session"
@@ -40,8 +55,10 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
 import open from "open"
 import { writeHeapSnapshot } from "v8"
 import { PromptRefProvider, usePromptRef } from "./context/prompt"
-import { TuiConfigProvider } from "./context/tui-config"
+import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
 import { TuiConfig } from "@/config/tui"
+import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
+import { FormatError, FormatUnknownError } from "@/cli/error"
 
 async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
   // can't set raw mode if not a TTY
@@ -104,7 +121,42 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
 }
 
 import type { EventSource } from "./context/sdk"
-import { Installation } from "@/installation"
+
+function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
+  return {
+    targetFps: 60,
+    gatherStats: false,
+    exitOnCtrlC: false,
+    useKittyKeyboard: { events: process.platform === "win32" },
+    autoFocus: false,
+    openConsoleOnError: false,
+    consoleOptions: {
+      keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
+      onCopySelection: (text) => {
+        Clipboard.copy(text).catch((error) => {
+          console.error(`Failed to copy console selection to clipboard: ${error}`)
+        })
+      },
+    },
+  }
+}
+
+function errorMessage(error: unknown) {
+  const formatted = FormatError(error)
+  if (formatted !== undefined) return formatted
+  if (
+    typeof error === "object" &&
+    error !== null &&
+    "data" in error &&
+    typeof error.data === "object" &&
+    error.data !== null &&
+    "message" in error.data &&
+    typeof error.data.message === "string"
+  ) {
+    return error.data.message
+  }
+  return FormatUnknownError(error)
+}
 
 export function tui(input: {
   url: string
@@ -132,77 +184,68 @@ export function tui(input: {
       resolve()
     }
 
-    render(
-      () => {
-        return (
-          <ErrorBoundary
-            fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
-          >
-            <ArgsProvider {...input.args}>
-              <ExitProvider onExit={onExit}>
-                <KVProvider>
-                  <ToastProvider>
-                    <RouteProvider>
-                      <TuiConfigProvider config={input.config}>
-                        <SDKProvider
-                          url={input.url}
-                          directory={input.directory}
-                          fetch={input.fetch}
-                          headers={input.headers}
-                          events={input.events}
-                        >
-                          <SyncProvider>
-                            <ThemeProvider mode={mode}>
-                              <LocalProvider>
-                                <KeybindProvider>
-                                  <PromptStashProvider>
-                                    <DialogProvider>
-                                      <CommandProvider>
-                                        <FrecencyProvider>
-                                          <PromptHistoryProvider>
-                                            <PromptRefProvider>
-                                              <App onSnapshot={input.onSnapshot} />
-                                            </PromptRefProvider>
-                                          </PromptHistoryProvider>
-                                        </FrecencyProvider>
-                                      </CommandProvider>
-                                    </DialogProvider>
-                                  </PromptStashProvider>
-                                </KeybindProvider>
-                              </LocalProvider>
-                            </ThemeProvider>
-                          </SyncProvider>
-                        </SDKProvider>
-                      </TuiConfigProvider>
-                    </RouteProvider>
-                  </ToastProvider>
-                </KVProvider>
-              </ExitProvider>
-            </ArgsProvider>
-          </ErrorBoundary>
-        )
-      },
-      {
-        targetFps: 60,
-        gatherStats: false,
-        exitOnCtrlC: false,
-        useKittyKeyboard: { events: process.platform === "win32" },
-        autoFocus: false,
-        openConsoleOnError: false,
-        consoleOptions: {
-          keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
-          onCopySelection: (text) => {
-            Clipboard.copy(text).catch((error) => {
-              console.error(`Failed to copy console selection to clipboard: ${error}`)
-            })
-          },
-        },
-      },
-    )
+    const onBeforeExit = async () => {
+      await TuiPluginRuntime.dispose()
+    }
+
+    const renderer = await createCliRenderer(rendererConfig(input.config))
+
+    await render(() => {
+      return (
+        <ErrorBoundary
+          fallback={(error, reset) => (
+            <ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
+          )}
+        >
+          <ArgsProvider {...input.args}>
+            <ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
+              <KVProvider>
+                <ToastProvider>
+                  <RouteProvider>
+                    <TuiConfigProvider config={input.config}>
+                      <SDKProvider
+                        url={input.url}
+                        directory={input.directory}
+                        fetch={input.fetch}
+                        headers={input.headers}
+                        events={input.events}
+                      >
+                        <SyncProvider>
+                          <ThemeProvider mode={mode}>
+                            <LocalProvider>
+                              <KeybindProvider>
+                                <PromptStashProvider>
+                                  <DialogProvider>
+                                    <CommandProvider>
+                                      <FrecencyProvider>
+                                        <PromptHistoryProvider>
+                                          <PromptRefProvider>
+                                            <App onSnapshot={input.onSnapshot} />
+                                          </PromptRefProvider>
+                                        </PromptHistoryProvider>
+                                      </FrecencyProvider>
+                                    </CommandProvider>
+                                  </DialogProvider>
+                                </PromptStashProvider>
+                              </KeybindProvider>
+                            </LocalProvider>
+                          </ThemeProvider>
+                        </SyncProvider>
+                      </SDKProvider>
+                    </TuiConfigProvider>
+                  </RouteProvider>
+                </ToastProvider>
+              </KVProvider>
+            </ExitProvider>
+          </ArgsProvider>
+        </ErrorBoundary>
+      )
+    }, renderer)
   })
 }
 
 function App(props: { onSnapshot?: () => Promise<string[]> }) {
+  const tuiConfig = useTuiConfig()
   const route = useRoute()
   const dimensions = useTerminalDimensions()
   const renderer = useRenderer()
@@ -211,12 +254,47 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
   const local = useLocal()
   const kv = useKV()
   const command = useCommandDialog()
+  const keybind = useKeybind()
   const sdk = useSDK()
   const toast = useToast()
-  const { theme, mode, setMode, locked, lock, unlock } = useTheme()
+  const themeState = useTheme()
+  const { theme, mode, setMode, locked, lock, unlock } = themeState
   const sync = useSync()
   const exit = useExit()
   const promptRef = usePromptRef()
+  const routes: RouteMap = new Map()
+  const [routeRev, setRouteRev] = createSignal(0)
+  const routeView = (name: string) => {
+    routeRev()
+    return routes.get(name)?.at(-1)?.render
+  }
+
+  const api = createTuiApi({
+    command,
+    tuiConfig,
+    dialog,
+    keybind,
+    kv,
+    route,
+    routes,
+    bump: () => setRouteRev((x) => x + 1),
+    sdk,
+    sync,
+    theme: themeState,
+    toast,
+    renderer,
+  })
+  onCleanup(() => {
+    api.dispose()
+  })
+  const [ready, setReady] = createSignal(false)
+  TuiPluginRuntime.init(api)
+    .catch((error) => {
+      console.error("Failed to load TUI plugins", error)
+    })
+    .finally(() => {
+      setReady(true)
+    })
 
   useKeyboard((evt) => {
     if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
@@ -259,10 +337,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
   }
   const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
 
-  createEffect(() => {
-    console.log(JSON.stringify(route.data))
-  })
-
   // Update terminal window title based on current route and session
   createEffect(() => {
     if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
@@ -279,9 +353,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
         return
       }
 
-      // Truncate title to 40 chars max
       const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
       renderer.setTerminalTitle(`OC | ${title}`)
+      return
+    }
+
+    if (route.data.type === "plugin") {
+      renderer.setTerminalTitle(`OC | ${route.data.id}`)
     }
   })
 
@@ -723,17 +801,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
   sdk.event.on("session.error", (evt) => {
     const error = evt.properties.error
     if (error && typeof error === "object" && error.name === "MessageAbortedError") return
-    const message = (() => {
-      if (!error) return "An error occurred"
-
-      if (typeof error === "object") {
-        const data = error.data
-        if ("message" in data && typeof data.message === "string") {
-          return data.message
-        }
-      }
-      return String(error)
-    })()
+    const message = errorMessage(error)
 
     toast.show({
       variant: "error",
@@ -789,6 +857,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
     exit()
   })
 
+  const plugin = createMemo(() => {
+    if (!ready()) return
+    if (route.data.type !== "plugin") return
+    const render = routeView(route.data.id)
+    if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
+    return render({ params: route.data.data })
+  })
+
   return (
     <box
       width={dimensions().width}
@@ -804,97 +880,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
       }}
       onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
     >
-      <Switch>
-        <Match when={route.data.type === "home"}>
-          <Home />
-        </Match>
-        <Match when={route.data.type === "session"}>
-          <Session />
-        </Match>
-      </Switch>
-    </box>
-  )
-}
-
-function ErrorComponent(props: {
-  error: Error
-  reset: () => void
-  onExit: () => Promise<void>
-  mode?: "dark" | "light"
-}) {
-  const term = useTerminalDimensions()
-  const renderer = useRenderer()
-
-  const handleExit = async () => {
-    renderer.setTerminalTitle("")
-    renderer.destroy()
-    win32FlushInputBuffer()
-    await props.onExit()
-  }
-
-  useKeyboard((evt) => {
-    if (evt.ctrl && evt.name === "c") {
-      handleExit()
-    }
-  })
-  const [copied, setCopied] = createSignal(false)
-
-  const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
-
-  // Choose safe fallback colors per mode since theme context may not be available
-  const isLight = props.mode === "light"
-  const colors = {
-    bg: isLight ? "#ffffff" : "#0a0a0a",
-    text: isLight ? "#1a1a1a" : "#eeeeee",
-    muted: isLight ? "#8a8a8a" : "#808080",
-    primary: isLight ? "#3b7dd8" : "#fab283",
-  }
-
-  if (props.error.message) {
-    issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
-  }
-
-  if (props.error.stack) {
-    issueURL.searchParams.set(
-      "description",
-      "```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
-    )
-  }
-
-  issueURL.searchParams.set("opencode-version", Installation.VERSION)
-
-  const copyIssueURL = () => {
-    Clipboard.copy(issueURL.toString()).then(() => {
-      setCopied(true)
-    })
-  }
-
-  return (
-    <box flexDirection="column" gap={1} backgroundColor={colors.bg}>
-      <box flexDirection="row" gap={1} alignItems="center">
-        <text attributes={TextAttributes.BOLD} fg={colors.text}>
-          Please report an issue.
-        </text>
-        <box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
-          <text attributes={TextAttributes.BOLD} fg={colors.bg}>
-            Copy issue URL (exception info pre-filled)
-          </text>
-        </box>
-        {copied() && <text fg={colors.muted}>Successfully copied</text>}
-      </box>
-      <box flexDirection="row" gap={2} alignItems="center">
-        <text fg={colors.text}>A fatal error occurred!</text>
-        <box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
-          <text fg={colors.bg}>Reset TUI</text>
-        </box>
-        <box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
-          <text fg={colors.bg}>Exit</text>
-        </box>
-      </box>
-      <scrollbox height={Math.floor(term().height * 0.7)}>
-        <text fg={colors.muted}>{props.error.stack}</text>
-      </scrollbox>
-      <text fg={colors.text}>{props.error.message}</text>
+      <Show when={Flag.OPENCODE_SHOW_TTFD}>
+        <TimeToFirstDraw />
+      </Show>
+      <Show when={ready()}>
+        <Switch>
+          <Match when={route.data.type === "home"}>
+            <Home />
+          </Match>
+          <Match when={route.data.type === "session"}>
+            <Session />
+          </Match>
+        </Switch>
+      </Show>
+      {plugin()}
+      <TuiPluginRuntime.Slot name="app" />
+      <StartupLoading ready={ready} />
     </box>
   )
 }

+ 30 - 6
packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx

@@ -4,13 +4,15 @@ import {
   createContext,
   createMemo,
   createSignal,
+  getOwner,
   onCleanup,
+  runWithOwner,
   useContext,
   type Accessor,
   type ParentProps,
 } from "solid-js"
 import { useKeyboard } from "@opentui/solid"
-import { type KeybindKey, useKeybind } from "@tui/context/keybind"
+import { useKeybind } from "@tui/context/keybind"
 
 type Context = ReturnType<typeof init>
 const ctx = createContext<Context>()
@@ -21,7 +23,7 @@ export type Slash = {
 }
 
 export type CommandOption = DialogSelectOption<string> & {
-  keybind?: KeybindKey
+  keybind?: string
   suggested?: boolean
   slash?: Slash
   hidden?: boolean
@@ -29,6 +31,7 @@ export type CommandOption = DialogSelectOption<string> & {
 }
 
 function init() {
+  const root = getOwner()
   const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
   const [suspendCount, setSuspendCount] = createSignal(0)
   const dialog = useDialog()
@@ -100,11 +103,32 @@ function init() {
       dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
     },
     register(cb: () => CommandOption[]) {
-      const results = createMemo(cb)
-      setRegistrations((arr) => [results, ...arr])
-      onCleanup(() => {
-        setRegistrations((arr) => arr.filter((x) => x !== results))
+      const owner = getOwner() ?? root
+      if (!owner) return () => {}
+
+      let list: Accessor<CommandOption[]> | undefined
+
+      // TUI plugins now register commands via an async store that runs outside an active reactive scope.
+      // runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
+      runWithOwner(owner, () => {
+        list = createMemo(cb)
+        const ref = list
+        if (!ref) return
+        setRegistrations((arr) => [ref, ...arr])
+        onCleanup(() => {
+          setRegistrations((arr) => arr.filter((x) => x !== ref))
+        })
       })
+
+      if (!list) return () => {}
+      let done = false
+      return () => {
+        if (done) return
+        done = true
+        const ref = list
+        if (!ref) return
+        setRegistrations((arr) => arr.filter((x) => x !== ref))
+      }
     },
   }
   return result

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

@@ -16,7 +16,8 @@ export function DialogStatus() {
 
   const plugins = createMemo(() => {
     const list = sync.data.config.plugin ?? []
-    const result = list.map((value) => {
+    const result = list.map((item) => {
+      const value = typeof item === "string" ? item : item[0]
       if (value.startsWith("file://")) {
         const path = fileURLToPath(value)
         const parts = path.split("/")

+ 13 - 20
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx

@@ -3,14 +3,22 @@ import { DialogSelect } from "@tui/ui/dialog-select"
 import { useRoute } from "@tui/context/route"
 import { useSync } from "@tui/context/sync"
 import { createEffect, createMemo, createSignal, onMount } from "solid-js"
-import type { Session } from "@opencode-ai/sdk/v2"
+import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
 import { useSDK } from "../context/sdk"
 import { useToast } from "../ui/toast"
 import { useKeybind } from "../context/keybind"
 import { DialogSessionList } from "./workspace/dialog-session-list"
-import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import { setTimeout as sleep } from "node:timers/promises"
 
+function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
+  return createOpencodeClient({
+    baseUrl: sdk.url,
+    fetch: sdk.fetch,
+    directory: sync.data.path.directory || sdk.directory,
+    experimental_workspaceID: workspaceID,
+  })
+}
+
 async function openWorkspace(input: {
   dialog: ReturnType<typeof useDialog>
   route: ReturnType<typeof useRoute>
@@ -29,12 +37,7 @@ async function openWorkspace(input: {
     )
   }
 
-  const client = createOpencodeClient({
-    baseUrl: input.sdk.url,
-    fetch: input.sdk.fetch,
-    directory: input.sync.data.path.directory || input.sdk.directory,
-    experimental_workspaceID: input.workspaceID,
-  })
+  const client = scoped(input.sdk, input.sync, input.workspaceID)
   const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
   const session = listed?.data?.[0]
   if (session?.id) {
@@ -187,12 +190,7 @@ export function DialogWorkspaceList() {
       await open(workspaceID)
       return
     }
-    const client = createOpencodeClient({
-      baseUrl: sdk.url,
-      fetch: sdk.fetch,
-      directory: sync.data.path.directory || sdk.directory,
-      experimental_workspaceID: workspaceID,
-    })
+    const client = scoped(sdk, sync, workspaceID)
     const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
     if (listed?.data?.length) {
       dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
@@ -223,12 +221,7 @@ export function DialogWorkspaceList() {
     setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
     void Promise.all(
       workspaces.map(async (workspace) => {
-        const client = createOpencodeClient({
-          baseUrl: sdk.url,
-          fetch: sdk.fetch,
-          directory: sync.data.path.directory || sdk.directory,
-          experimental_workspaceID: workspace.id,
-        })
+        const client = scoped(sdk, sync, workspace.id)
         const result = await client.session.list({ roots: true }).catch(() => undefined)
         return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
       }),

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

@@ -0,0 +1,91 @@
+import { TextAttributes } from "@opentui/core"
+import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
+import { Clipboard } from "@tui/util/clipboard"
+import { createSignal } from "solid-js"
+import { Installation } from "@/installation"
+import { win32FlushInputBuffer } from "../win32"
+
+export function ErrorComponent(props: {
+  error: Error
+  reset: () => void
+  onBeforeExit?: () => Promise<void>
+  onExit: () => Promise<void>
+  mode?: "dark" | "light"
+}) {
+  const term = useTerminalDimensions()
+  const renderer = useRenderer()
+
+  const handleExit = async () => {
+    await props.onBeforeExit?.()
+    renderer.setTerminalTitle("")
+    renderer.destroy()
+    win32FlushInputBuffer()
+    await props.onExit()
+  }
+
+  useKeyboard((evt) => {
+    if (evt.ctrl && evt.name === "c") {
+      handleExit()
+    }
+  })
+  const [copied, setCopied] = createSignal(false)
+
+  const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
+
+  // Choose safe fallback colors per mode since theme context may not be available
+  const isLight = props.mode === "light"
+  const colors = {
+    bg: isLight ? "#ffffff" : "#0a0a0a",
+    text: isLight ? "#1a1a1a" : "#eeeeee",
+    muted: isLight ? "#8a8a8a" : "#808080",
+    primary: isLight ? "#3b7dd8" : "#fab283",
+  }
+
+  if (props.error.message) {
+    issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
+  }
+
+  if (props.error.stack) {
+    issueURL.searchParams.set(
+      "description",
+      "```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
+    )
+  }
+
+  issueURL.searchParams.set("opencode-version", Installation.VERSION)
+
+  const copyIssueURL = () => {
+    Clipboard.copy(issueURL.toString()).then(() => {
+      setCopied(true)
+    })
+  }
+
+  return (
+    <box flexDirection="column" gap={1} backgroundColor={colors.bg}>
+      <box flexDirection="row" gap={1} alignItems="center">
+        <text attributes={TextAttributes.BOLD} fg={colors.text}>
+          Please report an issue.
+        </text>
+        <box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
+          <text attributes={TextAttributes.BOLD} fg={colors.bg}>
+            Copy issue URL (exception info pre-filled)
+          </text>
+        </box>
+        {copied() && <text fg={colors.muted}>Successfully copied</text>}
+      </box>
+      <box flexDirection="row" gap={2} alignItems="center">
+        <text fg={colors.text}>A fatal error occurred!</text>
+        <box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
+          <text fg={colors.bg}>Reset TUI</text>
+        </box>
+        <box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
+          <text fg={colors.bg}>Exit</text>
+        </box>
+      </box>
+      <scrollbox height={Math.floor(term().height * 0.7)}>
+        <text fg={colors.muted}>{props.error.stack}</text>
+      </scrollbox>
+      <text fg={colors.text}>{props.error.message}</text>
+    </box>
+  )
+}

+ 14 - 0
packages/opencode/src/cli/cmd/tui/component/plugin-route-missing.tsx

@@ -0,0 +1,14 @@
+import { useTheme } from "../context/theme"
+
+export function PluginRouteMissing(props: { id: string; onHome: () => void }) {
+  const { theme } = useTheme()
+
+  return (
+    <box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
+      <text fg={theme.warning}>Unknown plugin route: {props.id}</text>
+      <box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
+        <text fg={theme.text}>go home</text>
+      </box>
+    </box>
+  )
+}

+ 63 - 0
packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx

@@ -0,0 +1,63 @@
+import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
+import { useTheme } from "../context/theme"
+import { Spinner } from "./spinner"
+
+export function StartupLoading(props: { ready: () => boolean }) {
+  const theme = useTheme().theme
+  const [show, setShow] = createSignal(false)
+  const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
+  let wait: NodeJS.Timeout | undefined
+  let hold: NodeJS.Timeout | undefined
+  let stamp = 0
+
+  createEffect(() => {
+    if (props.ready()) {
+      if (wait) {
+        clearTimeout(wait)
+        wait = undefined
+      }
+      if (!show()) return
+      if (hold) return
+
+      const left = 3000 - (Date.now() - stamp)
+      if (left <= 0) {
+        setShow(false)
+        return
+      }
+
+      hold = setTimeout(() => {
+        hold = undefined
+        setShow(false)
+      }, left).unref()
+      return
+    }
+
+    if (hold) {
+      clearTimeout(hold)
+      hold = undefined
+    }
+    if (show()) return
+    if (wait) return
+
+    wait = setTimeout(() => {
+      wait = undefined
+      stamp = Date.now()
+      setShow(true)
+    }, 500).unref()
+  })
+
+  onCleanup(() => {
+    if (wait) clearTimeout(wait)
+    if (hold) clearTimeout(hold)
+  })
+
+  return (
+    <Show when={show()}>
+      <box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
+        <box backgroundColor={theme.backgroundPanel} paddingLeft={1} paddingRight={1}>
+          <Spinner color={theme.textMuted}>{text()}</Spinner>
+        </box>
+      </box>
+    </Show>
+  )
+}

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

@@ -12,7 +12,7 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
 
 export const { use: useExit, provider: ExitProvider } = createSimpleContext({
   name: "Exit",
-  init: (input: { onExit?: () => Promise<void> }) => {
+  init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
     const renderer = useRenderer()
     let message: string | undefined
     let task: Promise<void> | undefined
@@ -33,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
       (reason?: unknown) => {
         if (task) return task
         task = (async () => {
+          await input.onBeforeExit?.()
           // Reset window title before destroying renderer
           renderer.setTerminalTitle("")
           renderer.destroy()

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

@@ -80,21 +80,24 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
         }
         return Keybind.fromParsedKey(evt, store.leader)
       },
-      match(key: KeybindKey, evt: ParsedKey) {
-        const keybind = keybinds()[key]
-        if (!keybind) return false
+      match(key: string, evt: ParsedKey) {
+        const list = keybinds()[key] ?? Keybind.parse(key)
+        if (!list.length) return false
         const parsed: Keybind.Info = result.parse(evt)
-        for (const key of keybind) {
-          if (Keybind.match(key, parsed)) {
+        for (const item of list) {
+          if (Keybind.match(item, parsed)) {
             return true
           }
         }
+        return false
       },
-      print(key: KeybindKey) {
-        const first = keybinds()[key]?.at(0)
+      print(key: string) {
+        const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
         if (!first) return ""
-        const result = Keybind.toString(first)
-        return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
+        const text = Keybind.toString(first)
+        const lead = keybinds().leader?.[0]
+        if (!lead) return text
+        return text.replace("<leader>", Keybind.toString(lead))
       },
     }
     return result

+ 41 - 0
packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts

@@ -0,0 +1,41 @@
+import type { ParsedKey } from "@opentui/core"
+
+export type PluginKeybindMap = Record<string, string>
+
+type Base = {
+  match: (key: string, evt: ParsedKey) => boolean
+  print: (key: string) => string
+}
+
+export type PluginKeybind = {
+  readonly all: PluginKeybindMap
+  get: (name: string) => string
+  match: (name: string, evt: ParsedKey) => boolean
+  print: (name: string) => string
+}
+
+const txt = (value: unknown) => {
+  if (typeof value !== "string") return
+  if (!value.trim()) return
+  return value
+}
+
+export function createPluginKeybind(
+  base: Base,
+  defaults: PluginKeybindMap,
+  overrides?: Record<string, unknown>,
+): PluginKeybind {
+  const all = Object.freeze(
+    Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
+  )
+  const get = (name: string) => all[name] ?? name
+
+  return {
+    get all() {
+      return all
+    },
+    get,
+    match: (name, evt) => base.match(get(name), evt),
+    print: (name) => base.print(get(name)),
+  }
+}

+ 7 - 2
packages/opencode/src/cli/cmd/tui/context/route.tsx

@@ -14,7 +14,13 @@ export type SessionRoute = {
   initialPrompt?: PromptInfo
 }
 
-export type Route = HomeRoute | SessionRoute
+export type PluginRoute = {
+  type: "plugin"
+  id: string
+  data?: Record<string, unknown>
+}
+
+export type Route = HomeRoute | SessionRoute | PluginRoute
 
 export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
   name: "Route",
@@ -32,7 +38,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
         return store
       },
       navigate(route: Route) {
-        console.log("navigate", route)
         setStore(route)
       },
     }

+ 3 - 0
packages/opencode/src/cli/cmd/tui/context/sdk.tsx

@@ -109,6 +109,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       get client() {
         return sdk
       },
+      get workspaceID() {
+        return workspaceID
+      },
       directory: props.directory,
       event: emitter,
       fetch: props.fetch ?? fetch,

+ 132 - 110
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -42,66 +42,13 @@ import { createStore, produce } from "solid-js/store"
 import { Global } from "@/global"
 import { Filesystem } from "@/util/filesystem"
 import { useTuiConfig } from "./tui-config"
+import { isRecord } from "@/util/record"
+import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
 
-type ThemeColors = {
-  primary: RGBA
-  secondary: RGBA
-  accent: RGBA
-  error: RGBA
-  warning: RGBA
-  success: RGBA
-  info: RGBA
-  text: RGBA
-  textMuted: RGBA
-  selectedListItemText: RGBA
-  background: RGBA
-  backgroundPanel: RGBA
-  backgroundElement: RGBA
-  backgroundMenu: RGBA
-  border: RGBA
-  borderActive: RGBA
-  borderSubtle: RGBA
-  diffAdded: RGBA
-  diffRemoved: RGBA
-  diffContext: RGBA
-  diffHunkHeader: RGBA
-  diffHighlightAdded: RGBA
-  diffHighlightRemoved: RGBA
-  diffAddedBg: RGBA
-  diffRemovedBg: RGBA
-  diffContextBg: RGBA
-  diffLineNumber: RGBA
-  diffAddedLineNumberBg: RGBA
-  diffRemovedLineNumberBg: RGBA
-  markdownText: RGBA
-  markdownHeading: RGBA
-  markdownLink: RGBA
-  markdownLinkText: RGBA
-  markdownCode: RGBA
-  markdownBlockQuote: RGBA
-  markdownEmph: RGBA
-  markdownStrong: RGBA
-  markdownHorizontalRule: RGBA
-  markdownListItem: RGBA
-  markdownListEnumeration: RGBA
-  markdownImage: RGBA
-  markdownImageText: RGBA
-  markdownCodeBlock: RGBA
-  syntaxComment: RGBA
-  syntaxKeyword: RGBA
-  syntaxFunction: RGBA
-  syntaxVariable: RGBA
-  syntaxString: RGBA
-  syntaxNumber: RGBA
-  syntaxType: RGBA
-  syntaxOperator: RGBA
-  syntaxPunctuation: RGBA
-}
-
-type Theme = ThemeColors & {
+type Theme = TuiThemeCurrent & {
   _hasSelectedListItemText: boolean
-  thinkingOpacity: number
 }
+type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
 
 export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
   // If theme explicitly defines selectedListItemText, use it
@@ -128,10 +75,10 @@ type Variant = {
   light: HexColor | RefName
 }
 type ColorValue = HexColor | RefName | Variant | RGBA
-type ThemeJson = {
+export type ThemeJson = {
   $schema?: string
   defs?: Record<string, HexColor | RefName>
-  theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
+  theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
     selectedListItemText?: ColorValue
     backgroundMenu?: ColorValue
     thinkingOpacity?: number
@@ -174,27 +121,91 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
   carbonfox,
 }
 
-function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
+type State = {
+  themes: Record<string, ThemeJson>
+  mode: "dark" | "light"
+  lock: "dark" | "light" | undefined
+  active: string
+  ready: boolean
+}
+
+const pluginThemes: Record<string, ThemeJson> = {}
+let customThemes: Record<string, ThemeJson> = {}
+let systemTheme: ThemeJson | undefined
+
+function listThemes() {
+  // Priority: defaults < plugin installs < custom files < generated system.
+  const themes = {
+    ...DEFAULT_THEMES,
+    ...pluginThemes,
+    ...customThemes,
+  }
+  if (!systemTheme) return themes
+  return {
+    ...themes,
+    system: systemTheme,
+  }
+}
+
+function syncThemes() {
+  setStore("themes", listThemes())
+}
+
+const [store, setStore] = createStore<State>({
+  themes: listThemes(),
+  mode: "dark",
+  lock: undefined,
+  active: "opencode",
+  ready: false,
+})
+
+export function allThemes() {
+  return store.themes
+}
+
+function isTheme(theme: unknown): theme is ThemeJson {
+  if (!isRecord(theme)) return false
+  if (!isRecord(theme.theme)) return false
+  return true
+}
+
+export function hasTheme(name: string) {
+  if (!name) return false
+  return allThemes()[name] !== undefined
+}
+
+export function addTheme(name: string, theme: unknown) {
+  if (!name) return false
+  if (!isTheme(theme)) return false
+  if (hasTheme(name)) return false
+  pluginThemes[name] = theme
+  syncThemes()
+  return true
+}
+
+export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
   const defs = theme.defs ?? {}
-  function resolveColor(c: ColorValue): RGBA {
+  function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
     if (c instanceof RGBA) return c
     if (typeof c === "string") {
       if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
 
       if (c.startsWith("#")) return RGBA.fromHex(c)
 
-      if (defs[c] != null) {
-        return resolveColor(defs[c])
-      } else if (theme.theme[c as keyof ThemeColors] !== undefined) {
-        return resolveColor(theme.theme[c as keyof ThemeColors]!)
-      } else {
+      if (chain.includes(c)) {
+        throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`)
+      }
+
+      const next = defs[c] ?? theme.theme[c as ThemeColor]
+      if (next === undefined) {
         throw new Error(`Color reference "${c}" not found in defs or theme`)
       }
+      return resolveColor(next, [...chain, c])
     }
     if (typeof c === "number") {
       return ansiToRgba(c)
     }
-    return resolveColor(c[mode])
+    return resolveColor(c[mode], chain)
   }
 
   const resolved = Object.fromEntries(
@@ -203,7 +214,7 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
       .map(([key, value]) => {
         return [key, resolveColor(value as ColorValue)]
       }),
-  ) as Partial<ThemeColors>
+  ) as Partial<Record<ThemeColor, RGBA>>
 
   // Handle selectedListItemText separately since it's optional
   const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
@@ -287,14 +298,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       if (value === "dark" || value === "light") return value
       return
     }
-    const lock = pick(kv.get("theme_mode_lock"))
-    const [store, setStore] = createStore({
-      themes: DEFAULT_THEMES,
-      mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
-      lock,
-      active: (config.theme ?? kv.get("theme", "opencode")) as string,
-      ready: false,
-    })
+
+    setStore(
+      produce((draft) => {
+        const lock = pick(kv.get("theme_mode_lock"))
+        const mode = pick(kv.get("theme_mode", props.mode))
+        draft.mode = lock ?? mode ?? props.mode
+        draft.lock = lock
+        const active = config.theme ?? kv.get("theme", "opencode")
+        draft.active = typeof active === "string" ? active : "opencode"
+        draft.ready = false
+      }),
+    )
 
     createEffect(() => {
       const theme = config.theme
@@ -302,52 +317,46 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
     })
 
     function init() {
-      resolveSystemTheme(store.mode)
-      getCustomThemes()
-        .then((custom) => {
-          setStore(
-            produce((draft) => {
-              Object.assign(draft.themes, custom)
-            }),
-          )
-        })
-        .catch(() => {
-          setStore("active", "opencode")
-        })
-        .finally(() => {
-          if (store.active !== "system") {
-            setStore("ready", true)
-          }
-        })
+      Promise.allSettled([
+        resolveSystemTheme(store.mode),
+        getCustomThemes()
+          .then((custom) => {
+            customThemes = custom
+            syncThemes()
+          })
+          .catch(() => {
+            setStore("active", "opencode")
+          }),
+      ]).finally(() => {
+        setStore("ready", true)
+      })
     }
 
     onMount(init)
 
     function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
-      renderer
+      return renderer
         .getPalette({
           size: 16,
         })
-        .then((colors) => {
+        .then((colors: TerminalColors) => {
           if (!colors.palette[0]) {
+            systemTheme = undefined
+            syncThemes()
             if (store.active === "system") {
-              setStore(
-                produce((draft) => {
-                  draft.active = "opencode"
-                  draft.ready = true
-                }),
-              )
+              setStore("active", "opencode")
             }
             return
           }
-          setStore(
-            produce((draft) => {
-              draft.themes.system = generateSystem(colors, mode)
-              if (store.active === "system") {
-                draft.ready = true
-              }
-            }),
-          )
+          systemTheme = generateSystem(colors, mode)
+          syncThemes()
+        })
+        .catch(() => {
+          systemTheme = undefined
+          syncThemes()
+          if (store.active === "system") {
+            setStore("active", "opencode")
+          }
         })
     }
 
@@ -377,8 +386,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       apply(mode)
     }
     renderer.on(CliRenderEvents.THEME_MODE, handle)
+
+    const refresh = () => {
+      renderer.clearPaletteCache()
+      init()
+    }
+    process.on("SIGUSR2", refresh)
+
     onCleanup(() => {
       renderer.off(CliRenderEvents.THEME_MODE, handle)
+      process.off("SIGUSR2", refresh)
     })
 
     const values = createMemo(() => {
@@ -403,7 +420,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
         return store.active
       },
       all() {
-        return store.themes
+        return allThemes()
+      },
+      has(name: string) {
+        return hasTheme(name)
       },
       syntax,
       subtleSyntax,
@@ -423,8 +443,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
         pin(mode)
       },
       set(theme: string) {
+        if (!hasTheme(theme)) return false
         setStore("active", theme)
         kv.set("theme", theme)
+        return true
       },
       get ready() {
         return store.ready

+ 1 - 1
packages/opencode/src/cli/cmd/tui/component/tips.tsx → packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx

@@ -1,4 +1,4 @@
-import { createMemo, createSignal, For } from "solid-js"
+import { For } from "solid-js"
 import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
 
 const themeCount = Object.keys(DEFAULT_THEMES).length

+ 48 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx

@@ -0,0 +1,48 @@
+import type { TuiPlugin } from "@opencode-ai/plugin/tui"
+import { createMemo, Show } from "solid-js"
+import { Tips } from "./tips-view"
+
+const id = "internal:home-tips"
+
+function View(props: { show: boolean }) {
+  return (
+    <box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
+      <Show when={props.show}>
+        <Tips />
+      </Show>
+    </box>
+  )
+}
+
+const tui: TuiPlugin = async (api) => {
+  api.command.register(() => [
+    {
+      title: api.kv.get("tips_hidden", false) ? "Show tips" : "Hide tips",
+      value: "tips.toggle",
+      keybind: "tips_toggle",
+      category: "System",
+      hidden: api.route.current.name !== "home",
+      onSelect() {
+        api.kv.set("tips_hidden", !api.kv.get("tips_hidden", false))
+        api.ui.dialog.clear()
+      },
+    },
+  ])
+
+  api.slots.register({
+    order: 100,
+    slots: {
+      home_bottom() {
+        const hidden = createMemo(() => api.kv.get("tips_hidden", false))
+        const first = createMemo(() => api.state.session.count() === 0)
+        const show = createMemo(() => !first() && !hidden())
+        return <View show={show()} />
+      },
+    },
+  })
+}
+
+export default {
+  id,
+  tui,
+}

+ 61 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx

@@ -0,0 +1,61 @@
+import type { AssistantMessage } from "@opencode-ai/sdk/v2"
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo } from "solid-js"
+
+const id = "internal:sidebar-context"
+
+const money = new Intl.NumberFormat("en-US", {
+  style: "currency",
+  currency: "USD",
+})
+
+function View(props: { api: TuiPluginApi; session_id: string }) {
+  const theme = () => props.api.theme.current
+  const msg = createMemo(() => props.api.state.session.messages(props.session_id))
+  const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0))
+
+  const state = createMemo(() => {
+    const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
+    if (!last) {
+      return {
+        tokens: 0,
+        percent: null,
+      }
+    }
+
+    const tokens =
+      last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
+    const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
+    return {
+      tokens,
+      percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
+    }
+  })
+
+  return (
+    <box>
+      <text fg={theme().text}>
+        <b>Context</b>
+      </text>
+      <text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>
+      <text fg={theme().textMuted}>{state().percent ?? 0}% used</text>
+      <text fg={theme().textMuted}>{money.format(cost())} spent</text>
+    </box>
+  )
+}
+
+const tui: TuiPlugin = async (api) => {
+  api.slots.register({
+    order: 100,
+    slots: {
+      sidebar_content(_ctx, props) {
+        return <View api={api} session_id={props.session_id} />
+      },
+    },
+  })
+}
+
+export default {
+  id,
+  tui,
+}

+ 60 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx

@@ -0,0 +1,60 @@
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo, For, Show, createSignal } from "solid-js"
+
+const id = "internal:sidebar-files"
+
+function View(props: { api: TuiPluginApi; session_id: string }) {
+  const [open, setOpen] = createSignal(true)
+  const theme = () => props.api.theme.current
+  const list = createMemo(() => props.api.state.session.diff(props.session_id))
+
+  return (
+    <Show when={list().length > 0}>
+      <box>
+        <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
+          <Show when={list().length > 2}>
+            <text fg={theme().text}>{open() ? "▼" : "▶"}</text>
+          </Show>
+          <text fg={theme().text}>
+            <b>Modified Files</b>
+          </text>
+        </box>
+        <Show when={list().length <= 2 || open()}>
+          <For each={list()}>
+            {(item) => (
+              <box flexDirection="row" gap={1} justifyContent="space-between">
+                <text fg={theme().textMuted} wrapMode="none">
+                  {item.file}
+                </text>
+                <box flexDirection="row" gap={1} flexShrink={0}>
+                  <Show when={item.additions}>
+                    <text fg={theme().diffAdded}>+{item.additions}</text>
+                  </Show>
+                  <Show when={item.deletions}>
+                    <text fg={theme().diffRemoved}>-{item.deletions}</text>
+                  </Show>
+                </box>
+              </box>
+            )}
+          </For>
+        </Show>
+      </box>
+    </Show>
+  )
+}
+
+const tui: TuiPlugin = async (api) => {
+  api.slots.register({
+    order: 500,
+    slots: {
+      sidebar_content(_ctx, props) {
+        return <View api={api} session_id={props.session_id} />
+      },
+    },
+  })
+}
+
+export default {
+  id,
+  tui,
+}

+ 91 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx

@@ -0,0 +1,91 @@
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo, Show } from "solid-js"
+import { Global } from "@/global"
+
+const id = "internal:sidebar-footer"
+
+function View(props: { api: TuiPluginApi }) {
+  const theme = () => props.api.theme.current
+  const has = createMemo(() =>
+    props.api.state.provider.some(
+      (item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
+    ),
+  )
+  const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
+  const show = createMemo(() => !has() && !done())
+  const path = createMemo(() => {
+    const dir = props.api.state.path.directory || process.cwd()
+    const out = dir.replace(Global.Path.home, "~")
+    const text = props.api.state.vcs?.branch ? out + ":" + props.api.state.vcs.branch : out
+    const list = text.split("/")
+    return {
+      parent: list.slice(0, -1).join("/"),
+      name: list.at(-1) ?? "",
+    }
+  })
+
+  return (
+    <box gap={1}>
+      <Show when={show()}>
+        <box
+          backgroundColor={theme().backgroundElement}
+          paddingTop={1}
+          paddingBottom={1}
+          paddingLeft={2}
+          paddingRight={2}
+          flexDirection="row"
+          gap={1}
+        >
+          <text flexShrink={0} fg={theme().text}>
+            ⬖
+          </text>
+          <box flexGrow={1} gap={1}>
+            <box flexDirection="row" justifyContent="space-between">
+              <text fg={theme().text}>
+                <b>Getting started</b>
+              </text>
+              <text fg={theme().textMuted} onMouseDown={() => props.api.kv.set("dismissed_getting_started", true)}>
+                ✕
+              </text>
+            </box>
+            <text fg={theme().textMuted}>OpenCode includes free models so you can start immediately.</text>
+            <text fg={theme().textMuted}>
+              Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
+            </text>
+            <box flexDirection="row" gap={1} justifyContent="space-between">
+              <text fg={theme().text}>Connect provider</text>
+              <text fg={theme().textMuted}>/connect</text>
+            </box>
+          </box>
+        </box>
+      </Show>
+      <text>
+        <span style={{ fg: theme().textMuted }}>{path().parent}/</span>
+        <span style={{ fg: theme().text }}>{path().name}</span>
+      </text>
+      <text fg={theme().textMuted}>
+        <span style={{ fg: theme().success }}>•</span> <b>Open</b>
+        <span style={{ fg: theme().text }}>
+          <b>Code</b>
+        </span>{" "}
+        <span>{props.api.app.version}</span>
+      </text>
+    </box>
+  )
+}
+
+const tui: TuiPlugin = async (api) => {
+  api.slots.register({
+    order: 100,
+    slots: {
+      sidebar_footer() {
+        return <View api={api} />
+      },
+    },
+  })
+}
+
+export default {
+  id,
+  tui,
+}

+ 64 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx

@@ -0,0 +1,64 @@
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo, For, Show, createSignal } from "solid-js"
+
+const id = "internal:sidebar-lsp"
+
+function View(props: { api: TuiPluginApi }) {
+  const [open, setOpen] = createSignal(true)
+  const theme = () => props.api.theme.current
+  const list = createMemo(() => props.api.state.lsp())
+  const off = createMemo(() => props.api.state.config.lsp === false)
+
+  return (
+    <box>
+      <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
+        <Show when={list().length > 2}>
+          <text fg={theme().text}>{open() ? "▼" : "▶"}</text>
+        </Show>
+        <text fg={theme().text}>
+          <b>LSP</b>
+        </text>
+      </box>
+      <Show when={list().length <= 2 || open()}>
+        <Show when={list().length === 0}>
+          <text fg={theme().textMuted}>
+            {off() ? "LSPs have been disabled in settings" : "LSPs will activate as files are read"}
+          </text>
+        </Show>
+        <For each={list()}>
+          {(item) => (
+            <box flexDirection="row" gap={1}>
+              <text
+                flexShrink={0}
+                style={{
+                  fg: item.status === "connected" ? theme().success : theme().error,
+                }}
+              >
+                •
+              </text>
+              <text fg={theme().textMuted}>
+                {item.id} {item.root}
+              </text>
+            </box>
+          )}
+        </For>
+      </Show>
+    </box>
+  )
+}
+
+const tui: TuiPlugin = async (api) => {
+  api.slots.register({
+    order: 300,
+    slots: {
+      sidebar_content() {
+        return <View api={api} />
+      },
+    },
+  })
+}
+
+export default {
+  id,
+  tui,
+}

+ 94 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx

@@ -0,0 +1,94 @@
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
+
+const id = "internal:sidebar-mcp"
+
+function View(props: { api: TuiPluginApi }) {
+  const [open, setOpen] = createSignal(true)
+  const theme = () => props.api.theme.current
+  const list = createMemo(() => props.api.state.mcp())
+  const on = createMemo(() => list().filter((item) => item.status === "connected").length)
+  const bad = createMemo(
+    () =>
+      list().filter(
+        (item) =>
+          item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
+      ).length,
+  )
+
+  const dot = (status: string) => {
+    if (status === "connected") return theme().success
+    if (status === "failed") return theme().error
+    if (status === "disabled") return theme().textMuted
+    if (status === "needs_auth") return theme().warning
+    if (status === "needs_client_registration") return theme().error
+    return theme().textMuted
+  }
+
+  return (
+    <Show when={list().length > 0}>
+      <box>
+        <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
+          <Show when={list().length > 2}>
+            <text fg={theme().text}>{open() ? "▼" : "▶"}</text>
+          </Show>
+          <text fg={theme().text}>
+            <b>MCP</b>
+            <Show when={!open()}>
+              <span style={{ fg: theme().textMuted }}>
+                {" "}
+                ({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
+              </span>
+            </Show>
+          </text>
+        </box>
+        <Show when={list().length <= 2 || open()}>
+          <For each={list()}>
+            {(item) => (
+              <box flexDirection="row" gap={1}>
+                <text
+                  flexShrink={0}
+                  style={{
+                    fg: dot(item.status),
+                  }}
+                >
+                  •
+                </text>
+                <text fg={theme().text} wrapMode="word">
+                  {item.name}{" "}
+                  <span style={{ fg: theme().textMuted }}>
+                    <Switch fallback={item.status}>
+                      <Match when={item.status === "connected"}>Connected</Match>
+                      <Match when={item.status === "failed"}>
+                        <i>{item.error}</i>
+                      </Match>
+                      <Match when={item.status === "disabled"}>Disabled</Match>
+                      <Match when={item.status === "needs_auth"}>Needs auth</Match>
+                      <Match when={item.status === "needs_client_registration"}>Needs client ID</Match>
+                    </Switch>
+                  </span>
+                </text>
+              </box>
+            )}
+          </For>
+        </Show>
+      </box>
+    </Show>
+  )
+}
+
+const tui: TuiPlugin = async (api) => {
+  api.slots.register({
+    order: 200,
+    slots: {
+      sidebar_content() {
+        return <View api={api} />
+      },
+    },
+  })
+}
+
+export default {
+  id,
+  tui,
+}

+ 46 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx

@@ -0,0 +1,46 @@
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo, For, Show, createSignal } from "solid-js"
+import { TodoItem } from "../../component/todo-item"
+
+const id = "internal:sidebar-todo"
+
+function View(props: { api: TuiPluginApi; session_id: string }) {
+  const [open, setOpen] = createSignal(true)
+  const theme = () => props.api.theme.current
+  const list = createMemo(() => props.api.state.session.todo(props.session_id))
+  const show = createMemo(() => list().length > 0 && list().some((item) => item.status !== "completed"))
+
+  return (
+    <Show when={show()}>
+      <box>
+        <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
+          <Show when={list().length > 2}>
+            <text fg={theme().text}>{open() ? "▼" : "▶"}</text>
+          </Show>
+          <text fg={theme().text}>
+            <b>Todo</b>
+          </text>
+        </box>
+        <Show when={list().length <= 2 || open()}>
+          <For each={list()}>{(item) => <TodoItem status={item.status} content={item.content} />}</For>
+        </Show>
+      </box>
+    </Show>
+  )
+}
+
+const tui: TuiPlugin = async (api) => {
+  api.slots.register({
+    order: 400,
+    slots: {
+      sidebar_content(_ctx, props) {
+        return <View api={api} session_id={props.session_id} />
+      },
+    },
+  })
+}
+
+export default {
+  id,
+  tui,
+}

+ 262 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx

@@ -0,0 +1,262 @@
+import { Keybind } from "@/util/keybind"
+import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui"
+import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { fileURLToPath } from "url"
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
+import { createEffect, createMemo, createSignal } from "solid-js"
+
+const id = "internal:plugin-manager"
+const key = Keybind.parse("space").at(0)
+const add = Keybind.parse("shift+i").at(0)
+const tab = Keybind.parse("tab").at(0)
+
+function state(api: TuiPluginApi, item: TuiPluginStatus) {
+  if (!item.enabled) {
+    return <span style={{ fg: api.theme.current.textMuted }}>disabled</span>
+  }
+
+  return (
+    <span style={{ fg: item.active ? api.theme.current.success : api.theme.current.error }}>
+      {item.active ? "active" : "inactive"}
+    </span>
+  )
+}
+
+function source(spec: string) {
+  if (!spec.startsWith("file://")) return
+  return fileURLToPath(spec)
+}
+
+function meta(item: TuiPluginStatus, width: number) {
+  if (item.source === "internal") {
+    if (width >= 120) return "Built-in plugin"
+    return "Built-in"
+  }
+  const next = source(item.spec)
+  if (next) return next
+  return item.spec
+}
+
+function Install(props: { api: TuiPluginApi }) {
+  const [global, setGlobal] = createSignal(false)
+  const [busy, setBusy] = createSignal(false)
+
+  useKeyboard((evt) => {
+    if (evt.name !== "tab") return
+    evt.preventDefault()
+    evt.stopPropagation()
+    if (busy()) return
+    setGlobal((x) => !x)
+  })
+
+  return (
+    <props.api.ui.DialogPrompt
+      title="Install plugin"
+      placeholder="npm package name"
+      description={() => (
+        <box flexDirection="row" gap={1}>
+          <text fg={props.api.theme.current.textMuted}>scope:</text>
+          <text fg={props.api.theme.current.text}>{global() ? "global" : "local"}</text>
+          <text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
+        </box>
+      )}
+      onConfirm={(raw) => {
+        if (busy()) return
+        const mod = raw.trim()
+        if (!mod) {
+          props.api.ui.toast({
+            variant: "error",
+            message: "Plugin package name is required",
+          })
+          return
+        }
+
+        setBusy(true)
+        props.api.plugins
+          .install(mod, { global: global() })
+          .then((out) => {
+            if (!out.ok) {
+              props.api.ui.toast({
+                variant: "error",
+                message: out.message,
+              })
+              if (out.missing) {
+                props.api.ui.toast({
+                  variant: "info",
+                  message: "Check npm registry/auth settings and try again.",
+                })
+              }
+              show(props.api)
+              return
+            }
+
+            props.api.ui.toast({
+              variant: "success",
+              message: `Installed ${mod} (${global() ? "global" : "local"}: ${out.dir})`,
+            })
+            if (!out.tui) {
+              props.api.ui.toast({
+                variant: "info",
+                message: "Package has no TUI target to load in this app.",
+              })
+              show(props.api)
+              return
+            }
+
+            return props.api.plugins.add(mod).then((ok) => {
+              if (!ok) {
+                props.api.ui.toast({
+                  variant: "warning",
+                  message: "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.",
+                })
+                show(props.api)
+                return
+              }
+
+              props.api.ui.toast({
+                variant: "success",
+                message: `Loaded ${mod} in current session.`,
+              })
+              show(props.api)
+            })
+          })
+          .finally(() => {
+            setBusy(false)
+          })
+      }}
+      onCancel={() => {
+        show(props.api)
+      }}
+    />
+  )
+}
+
+function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption<string> {
+  return {
+    title: item.id,
+    value: item.id,
+    category: item.source === "internal" ? "Internal" : "External",
+    description: meta(item, width),
+    footer: state(api, item),
+    disabled: item.id === id,
+  }
+}
+
+function showInstall(api: TuiPluginApi) {
+  api.ui.dialog.replace(() => <Install api={api} />)
+}
+
+function View(props: { api: TuiPluginApi }) {
+  const size = useTerminalDimensions()
+  const [list, setList] = createSignal(props.api.plugins.list())
+  const [cur, setCur] = createSignal<string | undefined>()
+  const [lock, setLock] = createSignal(false)
+
+  createEffect(() => {
+    const width = size().width
+    if (width >= 128) {
+      props.api.ui.dialog.setSize("xlarge")
+      return
+    }
+    if (width >= 96) {
+      props.api.ui.dialog.setSize("large")
+      return
+    }
+    props.api.ui.dialog.setSize("medium")
+  })
+
+  const rows = createMemo(() =>
+    [...list()]
+      .sort((a, b) => {
+        const x = a.source === "internal" ? 1 : 0
+        const y = b.source === "internal" ? 1 : 0
+        if (x !== y) return x - y
+        return a.id.localeCompare(b.id)
+      })
+      .map((item) => row(props.api, item, size().width)),
+  )
+
+  const flip = (x: string) => {
+    if (lock()) return
+    const item = list().find((entry) => entry.id === x)
+    if (!item) return
+    setLock(true)
+    const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
+    task
+      .then((ok) => {
+        if (!ok) {
+          props.api.ui.toast({
+            variant: "error",
+            message: `Failed to update plugin ${item.id}`,
+          })
+        }
+        setList(props.api.plugins.list())
+      })
+      .finally(() => {
+        setLock(false)
+      })
+  }
+
+  return (
+    <DialogSelect
+      title="Plugins"
+      options={rows()}
+      current={cur()}
+      onMove={(item) => setCur(item.value)}
+      keybind={[
+        {
+          title: "toggle",
+          keybind: key,
+          disabled: lock(),
+          onTrigger: (item) => {
+            setCur(item.value)
+            flip(item.value)
+          },
+        },
+        {
+          title: "install",
+          keybind: add,
+          disabled: lock(),
+          onTrigger: () => {
+            showInstall(props.api)
+          },
+        },
+      ]}
+      onSelect={(item) => {
+        setCur(item.value)
+        flip(item.value)
+      }}
+    />
+  )
+}
+
+function show(api: TuiPluginApi) {
+  api.ui.dialog.replace(() => <View api={api} />)
+}
+
+const tui: TuiPlugin = async (api) => {
+  api.command.register(() => [
+    {
+      title: "Plugins",
+      value: "plugins.list",
+      keybind: "plugin_manager",
+      category: "System",
+      onSelect() {
+        show(api)
+      },
+    },
+    {
+      title: "Install plugin",
+      value: "plugins.install",
+      category: "System",
+      onSelect() {
+        showInstall(api)
+      },
+    },
+  ])
+}
+
+export default {
+  id,
+  tui,
+}

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

@@ -0,0 +1,406 @@
+import type { ParsedKey } from "@opentui/core"
+import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
+import type { useCommandDialog } from "@tui/component/dialog-command"
+import type { useKeybind } from "@tui/context/keybind"
+import type { useRoute } from "@tui/context/route"
+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/tui"
+import { createPluginKeybind } from "../context/plugin-keybinds"
+import type { useKV } from "../context/kv"
+import { DialogAlert } from "../ui/dialog-alert"
+import { DialogConfirm } from "../ui/dialog-confirm"
+import { DialogPrompt } from "../ui/dialog-prompt"
+import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
+import type { useToast } from "../ui/toast"
+import { Installation } from "@/installation"
+import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
+
+type RouteEntry = {
+  key: symbol
+  render: TuiRouteDefinition["render"]
+}
+
+export type RouteMap = Map<string, RouteEntry[]>
+
+type Input = {
+  command: ReturnType<typeof useCommandDialog>
+  tuiConfig: TuiConfig.Info
+  dialog: ReturnType<typeof useDialog>
+  keybind: ReturnType<typeof useKeybind>
+  kv: ReturnType<typeof useKV>
+  route: ReturnType<typeof useRoute>
+  routes: RouteMap
+  bump: () => void
+  sdk: ReturnType<typeof useSDK>
+  sync: ReturnType<typeof useSync>
+  theme: ReturnType<typeof useTheme>
+  toast: ReturnType<typeof useToast>
+  renderer: TuiPluginApi["renderer"]
+}
+
+type TuiHostPluginApi = TuiPluginApi & {
+  map: Map<string | undefined, OpencodeClient>
+  dispose: () => void
+}
+
+function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
+  const key = Symbol()
+  for (const item of list) {
+    const prev = routes.get(item.name) ?? []
+    prev.push({ key, render: item.render })
+    routes.set(item.name, prev)
+  }
+  bump()
+
+  return () => {
+    for (const item of list) {
+      const prev = routes.get(item.name)
+      if (!prev) continue
+      const next = prev.filter((x) => x.key !== key)
+      if (!next.length) {
+        routes.delete(item.name)
+        continue
+      }
+      routes.set(item.name, next)
+    }
+    bump()
+  }
+}
+
+function routeNavigate(route: ReturnType<typeof useRoute>, name: string, params?: Record<string, unknown>) {
+  if (name === "home") {
+    route.navigate({ type: "home" })
+    return
+  }
+
+  if (name === "session") {
+    const sessionID = params?.sessionID
+    if (typeof sessionID !== "string") return
+    route.navigate({ type: "session", sessionID })
+    return
+  }
+
+  route.navigate({ type: "plugin", id: name, data: params })
+}
+
+function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]["current"] {
+  if (route.data.type === "home") return { name: "home" }
+  if (route.data.type === "session") {
+    return {
+      name: "session",
+      params: {
+        sessionID: route.data.sessionID,
+        initialPrompt: route.data.initialPrompt,
+      },
+    }
+  }
+
+  return {
+    name: route.data.id,
+    params: route.data.data,
+  }
+}
+
+function mapOption<Value>(item: TuiDialogSelectOption<Value>): SelectOption<Value> {
+  return {
+    ...item,
+    onSelect: () => item.onSelect?.(),
+  }
+}
+
+function pickOption<Value>(item: SelectOption<Value>): TuiDialogSelectOption<Value> {
+  return {
+    title: item.title,
+    value: item.value,
+    description: item.description,
+    footer: item.footer,
+    category: item.category,
+    disabled: item.disabled,
+  }
+}
+
+function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
+  if (!cb) return
+  return (item: SelectOption<Value>) => cb(pickOption(item))
+}
+
+function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
+  return {
+    get ready() {
+      return sync.ready
+    },
+    get config() {
+      return sync.data.config
+    },
+    get provider() {
+      return sync.data.provider
+    },
+    get path() {
+      return sync.data.path
+    },
+    get vcs() {
+      if (!sync.data.vcs) return
+      return {
+        branch: sync.data.vcs.branch,
+      }
+    },
+    workspace: {
+      list() {
+        return sync.data.workspaceList
+      },
+      get(workspaceID) {
+        return sync.workspace.get(workspaceID)
+      },
+    },
+    session: {
+      count() {
+        return sync.data.session.length
+      },
+      diff(sessionID) {
+        return sync.data.session_diff[sessionID] ?? []
+      },
+      todo(sessionID) {
+        return sync.data.todo[sessionID] ?? []
+      },
+      messages(sessionID) {
+        return sync.data.message[sessionID] ?? []
+      },
+      status(sessionID) {
+        return sync.data.session_status[sessionID]
+      },
+      permission(sessionID) {
+        return sync.data.permission[sessionID] ?? []
+      },
+      question(sessionID) {
+        return sync.data.question[sessionID] ?? []
+      },
+    },
+    part(messageID) {
+      return sync.data.part[messageID] ?? []
+    },
+    lsp() {
+      return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
+    },
+    mcp() {
+      return Object.entries(sync.data.mcp)
+        .sort(([a], [b]) => a.localeCompare(b))
+        .map(([name, item]) => ({
+          name,
+          status: item.status,
+          error: item.status === "failed" ? item.error : undefined,
+        }))
+    },
+  }
+}
+
+function appApi(): TuiPluginApi["app"] {
+  return {
+    get version() {
+      return Installation.VERSION
+    },
+  }
+}
+
+export function createTuiApi(input: Input): TuiHostPluginApi {
+  const map = new Map<string | undefined, OpencodeClient>()
+  const scoped: TuiPluginApi["scopedClient"] = (workspaceID) => {
+    const hit = map.get(workspaceID)
+    if (hit) return hit
+
+    const next = createOpencodeClient({
+      baseUrl: input.sdk.url,
+      fetch: input.sdk.fetch,
+      directory: input.sync.data.path.directory || input.sdk.directory,
+      experimental_workspaceID: workspaceID,
+    })
+    map.set(workspaceID, next)
+    return next
+  }
+  const workspace: TuiPluginApi["workspace"] = {
+    current() {
+      return input.sdk.workspaceID
+    },
+    set(workspaceID) {
+      input.sdk.setWorkspace(workspaceID)
+    },
+  }
+  const lifecycle: TuiPluginApi["lifecycle"] = {
+    signal: new AbortController().signal,
+    onDispose() {
+      return () => {}
+    },
+  }
+
+  return {
+    app: appApi(),
+    command: {
+      register(cb) {
+        return input.command.register(() => cb())
+      },
+      trigger(value) {
+        input.command.trigger(value)
+      },
+    },
+    route: {
+      register(list) {
+        return routeRegister(input.routes, list, input.bump)
+      },
+      navigate(name, params) {
+        routeNavigate(input.route, name, params)
+      },
+      get current() {
+        return routeCurrent(input.route)
+      },
+    },
+    ui: {
+      Dialog(props) {
+        return (
+          <DialogUI size={props.size} onClose={props.onClose}>
+            {props.children}
+          </DialogUI>
+        )
+      },
+      DialogAlert(props) {
+        return <DialogAlert {...props} />
+      },
+      DialogConfirm(props) {
+        return <DialogConfirm {...props} />
+      },
+      DialogPrompt(props) {
+        return <DialogPrompt {...props} description={props.description} />
+      },
+      DialogSelect(props) {
+        return (
+          <DialogSelect
+            title={props.title}
+            placeholder={props.placeholder}
+            options={props.options.map(mapOption)}
+            flat={props.flat}
+            onMove={mapOptionCb(props.onMove)}
+            onFilter={props.onFilter}
+            onSelect={mapOptionCb(props.onSelect)}
+            skipFilter={props.skipFilter}
+            current={props.current}
+          />
+        )
+      },
+      toast(inputToast) {
+        input.toast.show({
+          title: inputToast.title,
+          message: inputToast.message,
+          variant: inputToast.variant ?? "info",
+          duration: inputToast.duration,
+        })
+      },
+      dialog: {
+        replace(render, onClose) {
+          input.dialog.replace(render, onClose)
+        },
+        clear() {
+          input.dialog.clear()
+        },
+        setSize(size) {
+          input.dialog.setSize(size)
+        },
+        get size() {
+          return input.dialog.size
+        },
+        get depth() {
+          return input.dialog.stack.length
+        },
+        get open() {
+          return input.dialog.stack.length > 0
+        },
+      },
+    },
+    keybind: {
+      match(key, evt: ParsedKey) {
+        return input.keybind.match(key, evt)
+      },
+      print(key) {
+        return input.keybind.print(key)
+      },
+      create(defaults, overrides) {
+        return createPluginKeybind(input.keybind, defaults, overrides)
+      },
+    },
+    get tuiConfig() {
+      return input.tuiConfig
+    },
+    kv: {
+      get(key, fallback) {
+        return input.kv.get(key, fallback)
+      },
+      set(key, value) {
+        input.kv.set(key, value)
+      },
+      get ready() {
+        return input.kv.ready
+      },
+    },
+    state: stateApi(input.sync),
+    get client() {
+      return input.sdk.client
+    },
+    scopedClient: scoped,
+    workspace,
+    event: input.sdk.event,
+    renderer: input.renderer,
+    slots: {
+      register() {
+        throw new Error("slots.register is only available in plugin context")
+      },
+    },
+    plugins: {
+      list() {
+        return []
+      },
+      async activate() {
+        return false
+      },
+      async deactivate() {
+        return false
+      },
+      async add() {
+        return false
+      },
+      async install() {
+        return {
+          ok: false,
+          message: "plugins.install is only available in plugin context",
+        }
+      },
+    },
+    lifecycle,
+    theme: {
+      get current() {
+        return input.theme.theme
+      },
+      get selected() {
+        return input.theme.selected
+      },
+      has(name) {
+        return input.theme.has(name)
+      },
+      set(name) {
+        return input.theme.set(name)
+      },
+      async install(_jsonPath) {
+        throw new Error("theme.install is only available in plugin context")
+      },
+      mode() {
+        return input.theme.mode()
+      },
+      get ready() {
+        return input.theme.ready
+      },
+    },
+    map,
+    dispose() {
+      map.clear()
+    },
+  }
+}

+ 3 - 0
packages/opencode/src/cli/cmd/tui/plugin/index.ts

@@ -0,0 +1,3 @@
+export { TuiPluginRuntime } from "./runtime"
+export { createTuiApi } from "./api"
+export type { RouteMap } from "./api"

+ 25 - 0
packages/opencode/src/cli/cmd/tui/plugin/internal.ts

@@ -0,0 +1,25 @@
+import HomeTips from "../feature-plugins/home/tips"
+import SidebarContext from "../feature-plugins/sidebar/context"
+import SidebarMcp from "../feature-plugins/sidebar/mcp"
+import SidebarLsp from "../feature-plugins/sidebar/lsp"
+import SidebarTodo from "../feature-plugins/sidebar/todo"
+import SidebarFiles from "../feature-plugins/sidebar/files"
+import SidebarFooter from "../feature-plugins/sidebar/footer"
+import PluginManager from "../feature-plugins/system/plugins"
+import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
+
+export type InternalTuiPlugin = TuiPluginModule & {
+  id: string
+  tui: TuiPlugin
+}
+
+export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
+  HomeTips,
+  SidebarContext,
+  SidebarMcp,
+  SidebarLsp,
+  SidebarTodo,
+  SidebarFiles,
+  SidebarFooter,
+  PluginManager,
+]

+ 972 - 0
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

@@ -0,0 +1,972 @@
+import "@opentui/solid/runtime-plugin-support"
+import {
+  type TuiDispose,
+  type TuiPlugin,
+  type TuiPluginApi,
+  type TuiPluginInstallResult,
+  type TuiPluginModule,
+  type TuiPluginMeta,
+  type TuiPluginStatus,
+  type TuiTheme,
+} from "@opencode-ai/plugin/tui"
+import path from "path"
+import { fileURLToPath } from "url"
+
+import { Config } from "@/config/config"
+import { TuiConfig } from "@/config/tui"
+import { Log } from "@/util/log"
+import { errorData, errorMessage } from "@/util/error"
+import { isRecord } from "@/util/record"
+import { Instance } from "@/project/instance"
+import {
+  checkPluginCompatibility,
+  getDefaultPlugin,
+  isDeprecatedPlugin,
+  pluginSource,
+  readPluginId,
+  resolvePluginEntrypoint,
+  resolvePluginId,
+  resolvePluginTarget,
+  type PluginSource,
+} from "@/plugin/shared"
+import { PluginMeta } from "@/plugin/meta"
+import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
+import { addTheme, hasTheme } from "../context/theme"
+import { Global } from "@/global"
+import { Filesystem } from "@/util/filesystem"
+import { Process } from "@/util/process"
+import { Flag } from "@/flag/flag"
+import { Installation } from "@/installation"
+import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
+import { setupSlots, Slot as View } from "./slots"
+import type { HostPluginApi, HostSlots } from "./slots"
+
+type PluginLoad = {
+  item?: Config.PluginSpec
+  spec: string
+  target: string
+  retry: boolean
+  source: PluginSource | "internal"
+  id: string
+  module: TuiPluginModule
+  install_theme: TuiTheme["install"]
+}
+
+type Api = HostPluginApi
+
+type PluginScope = {
+  lifecycle: TuiPluginApi["lifecycle"]
+  track: (fn: (() => void) | undefined) => () => void
+  dispose: () => Promise<void>
+}
+
+type PluginEntry = {
+  id: string
+  load: PluginLoad
+  meta: TuiPluginMeta
+  plugin: TuiPlugin
+  options: Config.PluginOptions | undefined
+  enabled: boolean
+  scope?: PluginScope
+}
+
+type RuntimeState = {
+  directory: string
+  api: Api
+  slots: HostSlots
+  plugins: PluginEntry[]
+  plugins_by_id: Map<string, PluginEntry>
+  pending: Map<
+    string,
+    {
+      item: Config.PluginSpec
+      meta: TuiConfig.PluginMeta
+    }
+  >
+}
+
+const log = Log.create({ service: "tui.plugin" })
+const DISPOSE_TIMEOUT_MS = 5000
+const KV_KEY = "plugin_enabled"
+
+function fail(message: string, data: Record<string, unknown>) {
+  if (!("error" in data)) {
+    log.error(message, data)
+    console.error(`[tui.plugin] ${message}`, data)
+    return
+  }
+
+  const text = `${message}: ${errorMessage(data.error)}`
+  const next = { ...data, error: errorData(data.error) }
+  log.error(text, next)
+  console.error(`[tui.plugin] ${text}`, next)
+}
+
+type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
+
+function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
+  return new Promise((resolve) => {
+    const timer = setTimeout(() => {
+      resolve({ type: "timeout" })
+    }, ms)
+
+    Promise.resolve()
+      .then(fn)
+      .then(
+        () => {
+          resolve({ type: "ok" })
+        },
+        (error) => {
+          resolve({ type: "error", error })
+        },
+      )
+      .finally(() => {
+        clearTimeout(timer)
+      })
+  })
+}
+
+function isTheme(value: unknown) {
+  if (!isRecord(value)) return false
+  if (!("theme" in value)) return false
+  if (!isRecord(value.theme)) return false
+  return true
+}
+
+function resolveRoot(root: string) {
+  if (root.startsWith("file://")) {
+    const file = fileURLToPath(root)
+    if (root.endsWith("/")) return file
+    return path.dirname(file)
+  }
+  if (path.isAbsolute(root)) return root
+  return path.resolve(process.cwd(), root)
+}
+
+function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] {
+  return async (file) => {
+    const raw = file.startsWith("file://") ? fileURLToPath(file) : file
+    const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
+    const theme = path.basename(src, path.extname(src))
+    if (hasTheme(theme)) return
+
+    const text = await Filesystem.readText(src).catch((error) => {
+      log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
+      return
+    })
+    if (text === undefined) return
+
+    const fail = Symbol()
+    const data = await Promise.resolve(text)
+      .then((x) => JSON.parse(x))
+      .catch((error) => {
+        log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
+        return fail
+      })
+    if (data === fail) return
+
+    if (!isTheme(data)) {
+      log.warn("invalid tui plugin theme", { path: spec, theme: src })
+      return
+    }
+
+    const source_dir = path.dirname(meta.source)
+    const local_dir =
+      path.basename(source_dir) === ".opencode"
+        ? path.join(source_dir, "themes")
+        : path.join(source_dir, ".opencode", "themes")
+    const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
+    const dest = path.join(dest_dir, `${theme}.json`)
+    if (!(await Filesystem.exists(dest))) {
+      await Filesystem.write(dest, text).catch((error) => {
+        log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
+      })
+    }
+
+    addTheme(theme, data)
+  }
+}
+
+async function loadExternalPlugin(
+  item: Config.PluginSpec,
+  meta: TuiConfig.PluginMeta | undefined,
+  retry = false,
+): Promise<PluginLoad | undefined> {
+  const spec = Config.pluginSpecifier(item)
+  if (isDeprecatedPlugin(spec)) return
+  log.info("loading tui plugin", { path: spec, retry })
+  const resolved = await resolvePluginTarget(spec).catch((error) => {
+    fail("failed to resolve tui plugin", { path: spec, retry, error })
+    return
+  })
+  if (!resolved) return
+
+  const source = pluginSource(spec)
+  if (source === "npm") {
+    const ok = await checkPluginCompatibility(resolved, Installation.VERSION)
+      .then(() => true)
+      .catch((error) => {
+        fail("tui plugin incompatible", { path: spec, retry, error })
+        return false
+      })
+    if (!ok) return
+  }
+
+  const target = resolved
+  if (!meta) {
+    fail("missing tui plugin metadata", {
+      path: spec,
+      retry,
+    })
+    return
+  }
+
+  const root = resolveRoot(source === "file" ? spec : target)
+  const install_theme = createThemeInstaller(meta, root, spec)
+  const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
+    fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
+    return
+  })
+  if (!entry) return
+
+  const mod = await import(entry)
+    .then((raw) => {
+      const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined
+      if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
+      return mod
+    })
+    .catch((error) => {
+      fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
+      return
+    })
+  if (!mod) return
+
+  const id = await resolvePluginId(source, spec, target, readPluginId(mod.id, spec)).catch((error) => {
+    fail("failed to load tui plugin", { path: spec, target, retry, error })
+    return
+  })
+  if (!id) return
+
+  return {
+    item,
+    spec,
+    target,
+    retry,
+    source,
+    id,
+    module: mod,
+    install_theme,
+  }
+}
+
+function createMeta(
+  source: PluginLoad["source"],
+  spec: string,
+  target: string,
+  meta: { state: PluginMeta.State; entry: PluginMeta.Entry } | undefined,
+  id?: string,
+): TuiPluginMeta {
+  if (meta) {
+    return {
+      state: meta.state,
+      ...meta.entry,
+    }
+  }
+
+  const now = Date.now()
+  return {
+    state: source === "internal" ? "same" : "first",
+    id: id ?? spec,
+    source,
+    spec,
+    target,
+    first_time: now,
+    last_time: now,
+    time_changed: now,
+    load_count: 1,
+    fingerprint: target,
+  }
+}
+
+function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
+  const spec = item.id
+  const target = spec
+
+  return {
+    spec,
+    target,
+    retry: false,
+    source: "internal",
+    id: item.id,
+    module: item,
+    install_theme: createThemeInstaller(
+      {
+        scope: "global",
+        source: target,
+      },
+      process.cwd(),
+      spec,
+    ),
+  }
+}
+
+function createPluginScope(load: PluginLoad, id: string) {
+  const ctrl = new AbortController()
+  let list: { key: symbol; fn: TuiDispose }[] = []
+  let done = false
+
+  const onDispose = (fn: TuiDispose) => {
+    if (done) return () => {}
+    const key = Symbol()
+    list.push({ key, fn })
+    let drop = false
+    return () => {
+      if (drop) return
+      drop = true
+      list = list.filter((x) => x.key !== key)
+    }
+  }
+
+  const track = (fn: (() => void) | undefined) => {
+    if (!fn) return () => {}
+    const off = onDispose(fn)
+    let drop = false
+    return () => {
+      if (drop) return
+      drop = true
+      off()
+      fn()
+    }
+  }
+
+  const lifecycle: TuiPluginApi["lifecycle"] = {
+    signal: ctrl.signal,
+    onDispose,
+  }
+
+  const dispose = async () => {
+    if (done) return
+    done = true
+    ctrl.abort()
+    const queue = [...list].reverse()
+    list = []
+    const until = Date.now() + DISPOSE_TIMEOUT_MS
+    for (const item of queue) {
+      const left = until - Date.now()
+      if (left <= 0) {
+        fail("timed out cleaning up tui plugin", {
+          path: load.spec,
+          id,
+          timeout: DISPOSE_TIMEOUT_MS,
+        })
+        break
+      }
+
+      const out = await runCleanup(item.fn, left)
+      if (out.type === "ok") continue
+      if (out.type === "timeout") {
+        fail("timed out cleaning up tui plugin", {
+          path: load.spec,
+          id,
+          timeout: DISPOSE_TIMEOUT_MS,
+        })
+        break
+      }
+
+      if (out.type === "error") {
+        fail("failed to clean up tui plugin", {
+          path: load.spec,
+          id,
+          error: out.error,
+        })
+      }
+    }
+  }
+
+  return {
+    lifecycle,
+    track,
+    dispose,
+  }
+}
+
+function readPluginEnabledMap(value: unknown) {
+  if (!isRecord(value)) return {}
+  return Object.fromEntries(
+    Object.entries(value).filter((item): item is [string, boolean] => typeof item[1] === "boolean"),
+  )
+}
+
+function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
+  return {
+    ...readPluginEnabledMap(config.plugin_enabled),
+    ...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
+  }
+}
+
+function writePluginEnabledState(api: Api, id: string, enabled: boolean) {
+  api.kv.set(KV_KEY, {
+    ...readPluginEnabledMap(api.kv.get(KV_KEY, {})),
+    [id]: enabled,
+  })
+}
+
+function listPluginStatus(state: RuntimeState): TuiPluginStatus[] {
+  return state.plugins.map((plugin) => ({
+    id: plugin.id,
+    source: plugin.meta.source,
+    spec: plugin.meta.spec,
+    target: plugin.meta.target,
+    enabled: plugin.enabled,
+    active: plugin.scope !== undefined,
+  }))
+}
+
+async function deactivatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
+  plugin.enabled = false
+  if (persist) writePluginEnabledState(state.api, plugin.id, false)
+  if (!plugin.scope) return true
+  const scope = plugin.scope
+  plugin.scope = undefined
+  await scope.dispose()
+  return true
+}
+
+async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
+  plugin.enabled = true
+  if (persist) writePluginEnabledState(state.api, plugin.id, true)
+  if (plugin.scope) return true
+
+  const scope = createPluginScope(plugin.load, plugin.id)
+  const api = pluginApi(state, plugin.load, scope, plugin.id)
+  const ok = await Promise.resolve()
+    .then(async () => {
+      await plugin.plugin(api, plugin.options, plugin.meta)
+      return true
+    })
+    .catch((error) => {
+      fail("failed to initialize tui plugin", {
+        path: plugin.load.spec,
+        id: plugin.id,
+        error,
+      })
+      return false
+    })
+
+  if (!ok) {
+    await scope.dispose()
+    return false
+  }
+
+  if (!plugin.enabled) {
+    await scope.dispose()
+    return true
+  }
+
+  plugin.scope = scope
+  return true
+}
+
+async function activatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
+  if (!state) return false
+  const plugin = state.plugins_by_id.get(id)
+  if (!plugin) return false
+  return activatePluginEntry(state, plugin, persist)
+}
+
+async function deactivatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
+  if (!state) return false
+  const plugin = state.plugins_by_id.get(id)
+  if (!plugin) return false
+  return deactivatePluginEntry(state, plugin, persist)
+}
+
+function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, base: string): TuiPluginApi {
+  const api = runtime.api
+  const host = runtime.slots
+  const command: TuiPluginApi["command"] = {
+    register(cb) {
+      return scope.track(api.command.register(cb))
+    },
+    trigger(value) {
+      api.command.trigger(value)
+    },
+  }
+
+  const route: TuiPluginApi["route"] = {
+    register(list) {
+      return scope.track(api.route.register(list))
+    },
+    navigate(name, params) {
+      api.route.navigate(name, params)
+    },
+    get current() {
+      return api.route.current
+    },
+  }
+
+  const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
+    install: load.install_theme,
+  })
+
+  const event: TuiPluginApi["event"] = {
+    on(type, handler) {
+      return scope.track(api.event.on(type, handler))
+    },
+  }
+
+  let count = 0
+
+  const slots: TuiPluginApi["slots"] = {
+    register(plugin) {
+      const id = count ? `${base}:${count}` : base
+      count += 1
+      scope.track(host.register({ ...plugin, id }))
+      return id
+    },
+  }
+
+  return {
+    app: api.app,
+    command,
+    route,
+    ui: api.ui,
+    keybind: api.keybind,
+    tuiConfig: api.tuiConfig,
+    kv: api.kv,
+    state: api.state,
+    theme,
+    get client() {
+      return api.client
+    },
+    scopedClient: api.scopedClient,
+    workspace: api.workspace,
+    event,
+    renderer: api.renderer,
+    slots,
+    plugins: {
+      list() {
+        return listPluginStatus(runtime)
+      },
+      activate(id) {
+        return activatePluginById(runtime, id, true)
+      },
+      deactivate(id) {
+        return deactivatePluginById(runtime, id, true)
+      },
+      add(spec) {
+        return addPluginBySpec(runtime, spec)
+      },
+      install(spec, options) {
+        return installPluginBySpec(runtime, spec, options?.global)
+      },
+    },
+    lifecycle: scope.lifecycle,
+  }
+}
+
+function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
+  // TUI stays default-only so plugin ids, lifecycle, and errors remain stable.
+  const plugin = load.module.tui
+  if (!plugin) return []
+  const options = load.item ? Config.pluginOptions(load.item) : undefined
+  return [
+    {
+      id: load.id,
+      load,
+      meta,
+      plugin,
+      options,
+      enabled: true,
+    },
+  ]
+}
+
+function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
+  if (state.plugins_by_id.has(plugin.id)) {
+    fail("duplicate tui plugin id", {
+      id: plugin.id,
+      path: plugin.load.spec,
+    })
+    return false
+  }
+
+  state.plugins_by_id.set(plugin.id, plugin)
+  state.plugins.push(plugin)
+  return true
+}
+
+function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
+  const map = pluginEnabledState(state, config)
+  for (const plugin of state.plugins) {
+    const enabled = map[plugin.id]
+    if (enabled === undefined) continue
+    plugin.enabled = enabled
+  }
+}
+
+async function resolveExternalPlugins(
+  list: Config.PluginSpec[],
+  wait: () => Promise<void>,
+  meta: (item: Config.PluginSpec) => TuiConfig.PluginMeta | undefined,
+) {
+  const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item, meta(item))))
+  const ready: PluginLoad[] = []
+  let deps: Promise<void> | undefined
+
+  for (let i = 0; i < list.length; i++) {
+    let entry = loaded[i]
+    if (!entry) {
+      const item = list[i]
+      if (!item) continue
+      const spec = Config.pluginSpecifier(item)
+      if (pluginSource(spec) !== "file") continue
+      deps ??= wait().catch((error) => {
+        log.warn("failed waiting for tui plugin dependencies", { error })
+      })
+      await deps
+      entry = await loadExternalPlugin(item, meta(item), true)
+    }
+    if (!entry) continue
+    ready.push(entry)
+  }
+
+  return ready
+}
+
+async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
+  if (!ready.length) return { plugins: [] as PluginEntry[], ok: true }
+
+  const meta = await PluginMeta.touchMany(
+    ready.map((item) => ({
+      spec: item.spec,
+      target: item.target,
+      id: item.id,
+    })),
+  ).catch((error) => {
+    log.warn("failed to track tui plugins", { error })
+    return undefined
+  })
+
+  const plugins: PluginEntry[] = []
+  let ok = true
+  for (let i = 0; i < ready.length; i++) {
+    const entry = ready[i]
+    if (!entry) continue
+    const hit = meta?.[i]
+    if (hit && hit.state !== "same") {
+      log.info("tui plugin metadata updated", {
+        path: entry.spec,
+        retry: entry.retry,
+        state: hit.state,
+        source: hit.entry.source,
+        version: hit.entry.version,
+        modified: hit.entry.modified,
+      })
+    }
+
+    const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
+    for (const plugin of collectPluginEntries(entry, row)) {
+      if (!addPluginEntry(state, plugin)) {
+        ok = false
+        continue
+      }
+      plugins.push(plugin)
+    }
+  }
+
+  return { plugins, ok }
+}
+
+function defaultPluginMeta(state: RuntimeState): TuiConfig.PluginMeta {
+  return {
+    scope: "local",
+    source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
+  }
+}
+
+function installCause(err: unknown) {
+  if (!err || typeof err !== "object") return
+  if (!("cause" in err)) return
+  return (err as { cause?: unknown }).cause
+}
+
+function installDetail(err: unknown) {
+  const hit = installCause(err) ?? err
+  if (!(hit instanceof Process.RunFailedError)) {
+    return {
+      message: errorMessage(hit),
+      missing: false,
+    }
+  }
+
+  const lines = hit.stderr
+    .toString()
+    .split(/\r?\n/)
+    .map((line) => line.trim())
+    .filter(Boolean)
+  const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
+  return {
+    message: errs[0] ?? lines.at(-1) ?? errorMessage(hit),
+    missing: lines.some((line) => line.includes("No version matching")),
+  }
+}
+
+async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
+  if (!state) return false
+  const spec = raw.trim()
+  if (!spec) return false
+
+  const pending = state.pending.get(spec)
+  const item = pending?.item ?? spec
+  const nextSpec = Config.pluginSpecifier(item)
+  if (state.plugins.some((plugin) => plugin.load.spec === nextSpec)) {
+    state.pending.delete(spec)
+    return true
+  }
+
+  const meta = pending?.meta ?? defaultPluginMeta(state)
+
+  const ready = await Instance.provide({
+    directory: state.directory,
+    fn: () =>
+      resolveExternalPlugins(
+        [item],
+        () => TuiConfig.waitForDependencies(),
+        () => meta,
+      ),
+  }).catch((error) => {
+    fail("failed to add tui plugin", { path: nextSpec, error })
+    return [] as PluginLoad[]
+  })
+  if (!ready.length) {
+    fail("failed to add tui plugin", { path: nextSpec })
+    return false
+  }
+
+  const first = ready[0]
+  if (!first) {
+    fail("failed to add tui plugin", { path: nextSpec })
+    return false
+  }
+  if (state.plugins_by_id.has(first.id)) {
+    state.pending.delete(spec)
+    return true
+  }
+
+  const out = await addExternalPluginEntries(state, [first])
+  let ok = out.ok && out.plugins.length > 0
+  for (const plugin of out.plugins) {
+    const active = await activatePluginEntry(state, plugin, false)
+    if (!active) ok = false
+  }
+
+  if (ok) state.pending.delete(spec)
+  if (!ok) {
+    fail("failed to add tui plugin", { path: nextSpec })
+  }
+  return ok
+}
+
+async function installPluginBySpec(
+  state: RuntimeState | undefined,
+  raw: string,
+  global = false,
+): Promise<TuiPluginInstallResult> {
+  if (!state) {
+    return {
+      ok: false,
+      message: "Plugin runtime is not ready.",
+    }
+  }
+
+  const spec = raw.trim()
+  if (!spec) {
+    return {
+      ok: false,
+      message: "Plugin package name is required",
+    }
+  }
+
+  const dir = state.api.state.path
+  if (!dir.directory) {
+    return {
+      ok: false,
+      message: "Paths are still syncing. Try again in a moment.",
+    }
+  }
+
+  const install = await installModulePlugin(spec)
+  if (!install.ok) {
+    const out = installDetail(install.error)
+    return {
+      ok: false,
+      message: out.message,
+      missing: out.missing,
+    }
+  }
+
+  const manifest = await readPluginManifest(install.target)
+  if (!manifest.ok) {
+    if (manifest.code === "manifest_no_targets") {
+      return {
+        ok: false,
+        message: `"${spec}" does not declare supported targets in package.json`,
+      }
+    }
+
+    return {
+      ok: false,
+      message: `Installed "${spec}" but failed to read ${manifest.file}`,
+    }
+  }
+
+  const patch = await patchPluginConfig({
+    spec,
+    targets: manifest.targets,
+    global,
+    vcs: dir.worktree && dir.worktree !== "/" ? "git" : undefined,
+    worktree: dir.worktree,
+    directory: dir.directory,
+  })
+  if (!patch.ok) {
+    if (patch.code === "invalid_json") {
+      return {
+        ok: false,
+        message: `Invalid JSON in ${patch.file} (${patch.parse} at line ${patch.line}, column ${patch.col})`,
+      }
+    }
+
+    return {
+      ok: false,
+      message: errorMessage(patch.error),
+    }
+  }
+
+  const tui = manifest.targets.find((item) => item.kind === "tui")
+  if (tui) {
+    const file = patch.items.find((item) => item.kind === "tui")?.file
+    state.pending.set(spec, {
+      item: tui.opts ? [spec, tui.opts] : spec,
+      meta: {
+        scope: global ? "global" : "local",
+        source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
+      },
+    })
+  }
+
+  return {
+    ok: true,
+    dir: patch.dir,
+    tui: Boolean(tui),
+  }
+}
+
+export namespace TuiPluginRuntime {
+  let dir = ""
+  let loaded: Promise<void> | undefined
+  let runtime: RuntimeState | undefined
+  export const Slot = View
+
+  export async function init(api: HostPluginApi) {
+    const cwd = process.cwd()
+    if (loaded) {
+      if (dir !== cwd) {
+        throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
+      }
+      return loaded
+    }
+
+    dir = cwd
+    loaded = load(api)
+    return loaded
+  }
+
+  export function list() {
+    if (!runtime) return []
+    return listPluginStatus(runtime)
+  }
+
+  export async function activatePlugin(id: string) {
+    return activatePluginById(runtime, id, true)
+  }
+
+  export async function deactivatePlugin(id: string) {
+    return deactivatePluginById(runtime, id, true)
+  }
+
+  export async function addPlugin(spec: string) {
+    return addPluginBySpec(runtime, spec)
+  }
+
+  export async function installPlugin(spec: string, options?: { global?: boolean }) {
+    return installPluginBySpec(runtime, spec, options?.global)
+  }
+
+  export async function dispose() {
+    const task = loaded
+    loaded = undefined
+    dir = ""
+    if (task) await task
+    const state = runtime
+    runtime = undefined
+    if (!state) return
+    const queue = [...state.plugins].reverse()
+    for (const plugin of queue) {
+      await deactivatePluginEntry(state, plugin, false)
+    }
+  }
+
+  async function load(api: Api) {
+    const cwd = process.cwd()
+    const slots = setupSlots(api)
+    const next: RuntimeState = {
+      directory: cwd,
+      api,
+      slots,
+      plugins: [],
+      plugins_by_id: new Map(),
+      pending: new Map(),
+    }
+    runtime = next
+
+    await Instance.provide({
+      directory: cwd,
+      fn: async () => {
+        const config = await TuiConfig.get()
+        const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? [])
+        if (Flag.OPENCODE_PURE && config.plugin?.length) {
+          log.info("skipping external tui plugins in pure mode", { count: config.plugin.length })
+        }
+
+        for (const item of INTERNAL_TUI_PLUGINS) {
+          log.info("loading internal tui plugin", { id: item.id })
+          const entry = loadInternalPlugin(item)
+          const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
+          for (const plugin of collectPluginEntries(entry, meta)) {
+            addPluginEntry(next, plugin)
+          }
+        }
+
+        const ready = await resolveExternalPlugins(
+          plugins,
+          () => TuiConfig.waitForDependencies(),
+          (item) => config.plugin_meta?.[Config.pluginSpecifier(item)],
+        )
+        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 })
+    })
+  }
+}

+ 61 - 0
packages/opencode/src/cli/cmd/tui/plugin/slots.tsx

@@ -0,0 +1,61 @@
+import { type SlotMode, type TuiPluginApi, type TuiSlotContext, type TuiSlotMap } from "@opencode-ai/plugin/tui"
+import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
+import { isRecord } from "@/util/record"
+
+type SlotProps<K extends keyof TuiSlotMap> = {
+  name: K
+  mode?: SlotMode
+  children?: JSX.Element
+} & TuiSlotMap[K]
+
+type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
+export type HostSlotPlugin = SolidPlugin<TuiSlotMap, TuiSlotContext>
+
+export type HostPluginApi = TuiPluginApi
+export type HostSlots = {
+  register: (plugin: HostSlotPlugin) => () => void
+}
+
+function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
+  return null
+}
+
+let view: Slot = empty
+
+export const Slot: Slot = (props) => view(props)
+
+function isHostSlotPlugin(value: unknown): value is HostSlotPlugin {
+  if (!isRecord(value)) return false
+  if (typeof value.id !== "string") return false
+  if (!isRecord(value.slots)) return false
+  return true
+}
+
+export function setupSlots(api: HostPluginApi): HostSlots {
+  const reg = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(
+    api.renderer,
+    {
+      theme: api.theme,
+    },
+    {
+      onPluginError(event) {
+        console.error("[tui.slot] plugin error", {
+          plugin: event.pluginId,
+          slot: event.slot,
+          phase: event.phase,
+          source: event.source,
+          message: event.error.message,
+        })
+      },
+    },
+  )
+
+  const slot = createSlot<TuiSlotMap, TuiSlotContext>(reg)
+  view = (props) => slot(props)
+  return {
+    register(plugin) {
+      if (!isHostSlotPlugin(plugin)) return () => {}
+      return reg.register(plugin)
+    },
+  }
+}

+ 9 - 39
packages/opencode/src/cli/cmd/tui/routes/home.tsx

@@ -1,9 +1,7 @@
 import { Prompt, type PromptRef } from "@tui/component/prompt"
 import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
 import { useTheme } from "@tui/context/theme"
-import { useKeybind } from "@tui/context/keybind"
 import { Logo } from "../component/logo"
-import { Tips } from "../component/tips"
 import { Locale } from "@/util/locale"
 import { useSync } from "../context/sync"
 import { Toast } from "../ui/toast"
@@ -12,20 +10,17 @@ import { useDirectory } from "../context/directory"
 import { useRouteData } from "@tui/context/route"
 import { usePromptRef } from "../context/prompt"
 import { Installation } from "@/installation"
-import { useKV } from "../context/kv"
-import { useCommandDialog } from "../component/dialog-command"
 import { useLocal } from "../context/local"
+import { TuiPluginRuntime } from "../plugin"
 
 // TODO: what is the best way to do this?
 let once = false
 
 export function Home() {
   const sync = useSync()
-  const kv = useKV()
   const { theme } = useTheme()
   const route = useRouteData("home")
   const promptRef = usePromptRef()
-  const command = useCommandDialog()
   const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
   const mcpError = createMemo(() => {
     return Object.values(sync.data.mcp).some((x) => x.status === "failed")
@@ -35,30 +30,9 @@ export function Home() {
     return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
   })
 
-  const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
-  const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
-  const showTips = createMemo(() => {
-    // Don't show tips for first-time users
-    if (isFirstTimeUser()) return false
-    return !tipsHidden()
-  })
-
-  command.register(() => [
-    {
-      title: tipsHidden() ? "Show tips" : "Hide tips",
-      value: "tips.toggle",
-      keybind: "tips_toggle",
-      category: "System",
-      onSelect: (dialog) => {
-        kv.set("tips_hidden", !tipsHidden())
-        dialog.clear()
-      },
-    },
-  ])
-
   const Hint = (
-    <Show when={connectedMcpCount() > 0}>
-      <box flexShrink={0} flexDirection="row" gap={1}>
+    <box flexShrink={0} flexDirection="row" gap={1}>
+      <Show when={connectedMcpCount() > 0}>
         <text fg={theme.text}>
           <Switch>
             <Match when={mcpError()}>
@@ -71,8 +45,8 @@ export function Home() {
             </Match>
           </Switch>
         </text>
-      </box>
-    </Show>
+      </Show>
+    </box>
   )
 
   let prompt: PromptRef
@@ -103,15 +77,15 @@ export function Home() {
   )
   const directory = useDirectory()
 
-  const keybind = useKeybind()
-
   return (
     <>
       <box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
         <box flexGrow={1} minHeight={0} />
         <box height={4} minHeight={0} flexShrink={1} />
         <box flexShrink={0}>
-          <Logo />
+          <TuiPluginRuntime.Slot name="home_logo" mode="replace">
+            <Logo />
+          </TuiPluginRuntime.Slot>
         </box>
         <box height={1} minHeight={0} flexShrink={1} />
         <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
@@ -124,11 +98,7 @@ export function Home() {
             workspaceID={route.workspaceID}
           />
         </box>
-        <box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
-          <Show when={showTips()}>
-            <Tips />
-          </Show>
-        </box>
+        <TuiPluginRuntime.Slot name="home_bottom" />
         <box flexGrow={1} minHeight={0} />
         <Toast />
       </box>

+ 0 - 1
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -70,7 +70,6 @@ import { Toast, useToast } from "../../ui/toast"
 import { useKV } from "../../context/kv.tsx"
 import { Editor } from "../../util/editor"
 import stripAnsi from "strip-ansi"
-import { Footer } from "./footer.tsx"
 import { usePromptRef } from "../../context/prompt"
 import { useExit } from "../../context/exit"
 import { Filesystem } from "@/util/filesystem"

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

@@ -1,72 +1,13 @@
 import { useSync } from "@tui/context/sync"
-import { createMemo, For, Show, Switch, Match } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createMemo, Show } from "solid-js"
 import { useTheme } from "../../context/theme"
-import { Locale } from "@/util/locale"
-import path from "path"
-import type { AssistantMessage } from "@opencode-ai/sdk/v2"
-import { Global } from "@/global"
 import { Installation } from "@/installation"
-import { useKeybind } from "../../context/keybind"
-import { useDirectory } from "../../context/directory"
-import { useKV } from "../../context/kv"
-import { TodoItem } from "../../component/todo-item"
+import { TuiPluginRuntime } from "../../plugin"
 
 export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
   const sync = useSync()
   const { theme } = useTheme()
-  const session = createMemo(() => sync.session.get(props.sessionID)!)
-  const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? [])
-  const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
-  const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
-
-  const [expanded, setExpanded] = createStore({
-    mcp: true,
-    diff: true,
-    todo: true,
-    lsp: true,
-  })
-
-  // Sort MCP servers alphabetically for consistent display order
-  const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
-
-  // Count connected and error MCP servers for collapsed header display
-  const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length)
-  const errorMcpCount = createMemo(
-    () =>
-      mcpEntries().filter(
-        ([_, item]) =>
-          item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
-      ).length,
-  )
-
-  const cost = createMemo(() => {
-    const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
-    return new Intl.NumberFormat("en-US", {
-      style: "currency",
-      currency: "USD",
-    }).format(total)
-  })
-
-  const context = createMemo(() => {
-    const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
-    if (!last) return
-    const total =
-      last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
-    const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
-    return {
-      tokens: total.toLocaleString(),
-      percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
-    }
-  })
-
-  const directory = useDirectory()
-  const kv = useKV()
-
-  const hasProviders = createMemo(() =>
-    sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
-  )
-  const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false))
+  const session = createMemo(() => sync.session.get(props.sessionID))
 
   return (
     <Show when={session()}>
@@ -90,230 +31,36 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
           }}
         >
           <box flexShrink={0} gap={1} paddingRight={1}>
-            <box paddingRight={1}>
-              <text fg={theme.text}>
-                <b>{session().title}</b>
-              </text>
-              <Show when={session().share?.url}>
-                <text fg={theme.textMuted}>{session().share!.url}</text>
-              </Show>
-            </box>
-            <box>
-              <text fg={theme.text}>
-                <b>Context</b>
-              </text>
-              <text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
-              <text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
-              <text fg={theme.textMuted}>{cost()} spent</text>
-            </box>
-            <Show when={mcpEntries().length > 0}>
-              <box>
-                <box
-                  flexDirection="row"
-                  gap={1}
-                  onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)}
-                >
-                  <Show when={mcpEntries().length > 2}>
-                    <text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
-                  </Show>
-                  <text fg={theme.text}>
-                    <b>MCP</b>
-                    <Show when={!expanded.mcp}>
-                      <span style={{ fg: theme.textMuted }}>
-                        {" "}
-                        ({connectedMcpCount()} active
-                        {errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""})
-                      </span>
-                    </Show>
-                  </text>
-                </box>
-                <Show when={mcpEntries().length <= 2 || expanded.mcp}>
-                  <For each={mcpEntries()}>
-                    {([key, item]) => (
-                      <box flexDirection="row" gap={1}>
-                        <text
-                          flexShrink={0}
-                          style={{
-                            fg: (
-                              {
-                                connected: theme.success,
-                                failed: theme.error,
-                                disabled: theme.textMuted,
-                                needs_auth: theme.warning,
-                                needs_client_registration: theme.error,
-                              } as Record<string, typeof theme.success>
-                            )[item.status],
-                          }}
-                        >
-                          •
-                        </text>
-                        <text fg={theme.text} wrapMode="word">
-                          {key}{" "}
-                          <span style={{ fg: theme.textMuted }}>
-                            <Switch fallback={item.status}>
-                              <Match when={item.status === "connected"}>Connected</Match>
-                              <Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
-                              <Match when={item.status === "disabled"}>Disabled</Match>
-                              <Match when={(item.status as string) === "needs_auth"}>Needs auth</Match>
-                              <Match when={(item.status as string) === "needs_client_registration"}>
-                                Needs client ID
-                              </Match>
-                            </Switch>
-                          </span>
-                        </text>
-                      </box>
-                    )}
-                  </For>
-                </Show>
-              </box>
-            </Show>
-            <box>
-              <box
-                flexDirection="row"
-                gap={1}
-                onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)}
-              >
-                <Show when={sync.data.lsp.length > 2}>
-                  <text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
-                </Show>
+            <TuiPluginRuntime.Slot
+              name="sidebar_title"
+              mode="single_winner"
+              session_id={props.sessionID}
+              title={session()!.title}
+              share_url={session()!.share?.url}
+            >
+              <box paddingRight={1}>
                 <text fg={theme.text}>
-                  <b>LSP</b>
+                  <b>{session()!.title}</b>
                 </text>
-              </box>
-              <Show when={sync.data.lsp.length <= 2 || expanded.lsp}>
-                <Show when={sync.data.lsp.length === 0}>
-                  <text fg={theme.textMuted}>
-                    {sync.data.config.lsp === false
-                      ? "LSPs have been disabled in settings"
-                      : "LSPs will activate as files are read"}
-                  </text>
-                </Show>
-                <For each={sync.data.lsp}>
-                  {(item) => (
-                    <box flexDirection="row" gap={1}>
-                      <text
-                        flexShrink={0}
-                        style={{
-                          fg: {
-                            connected: theme.success,
-                            error: theme.error,
-                          }[item.status],
-                        }}
-                      >
-                        •
-                      </text>
-                      <text fg={theme.textMuted}>
-                        {item.id} {item.root}
-                      </text>
-                    </box>
-                  )}
-                </For>
-              </Show>
-            </box>
-            <Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
-              <box>
-                <box
-                  flexDirection="row"
-                  gap={1}
-                  onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
-                >
-                  <Show when={todo().length > 2}>
-                    <text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
-                  </Show>
-                  <text fg={theme.text}>
-                    <b>Todo</b>
-                  </text>
-                </box>
-                <Show when={todo().length <= 2 || expanded.todo}>
-                  <For each={todo()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
+                <Show when={session()!.share?.url}>
+                  <text fg={theme.textMuted}>{session()!.share!.url}</text>
                 </Show>
               </box>
-            </Show>
-            <Show when={diff().length > 0}>
-              <box>
-                <box
-                  flexDirection="row"
-                  gap={1}
-                  onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
-                >
-                  <Show when={diff().length > 2}>
-                    <text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
-                  </Show>
-                  <text fg={theme.text}>
-                    <b>Modified Files</b>
-                  </text>
-                </box>
-                <Show when={diff().length <= 2 || expanded.diff}>
-                  <For each={diff() || []}>
-                    {(item) => {
-                      return (
-                        <box flexDirection="row" gap={1} justifyContent="space-between">
-                          <text fg={theme.textMuted} wrapMode="none">
-                            {item.file}
-                          </text>
-                          <box flexDirection="row" gap={1} flexShrink={0}>
-                            <Show when={item.additions}>
-                              <text fg={theme.diffAdded}>+{item.additions}</text>
-                            </Show>
-                            <Show when={item.deletions}>
-                              <text fg={theme.diffRemoved}>-{item.deletions}</text>
-                            </Show>
-                          </box>
-                        </box>
-                      )
-                    }}
-                  </For>
-                </Show>
-              </box>
-            </Show>
+            </TuiPluginRuntime.Slot>
+            <TuiPluginRuntime.Slot name="sidebar_content" session_id={props.sessionID} />
           </box>
         </scrollbox>
 
         <box flexShrink={0} gap={1} paddingTop={1}>
-          <Show when={!hasProviders() && !gettingStartedDismissed()}>
-            <box
-              backgroundColor={theme.backgroundElement}
-              paddingTop={1}
-              paddingBottom={1}
-              paddingLeft={2}
-              paddingRight={2}
-              flexDirection="row"
-              gap={1}
-            >
-              <text flexShrink={0} fg={theme.text}>
-                ⬖
-              </text>
-              <box flexGrow={1} gap={1}>
-                <box flexDirection="row" justifyContent="space-between">
-                  <text fg={theme.text}>
-                    <b>Getting started</b>
-                  </text>
-                  <text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}>
-                    ✕
-                  </text>
-                </box>
-                <text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
-                <text fg={theme.textMuted}>
-                  Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
-                </text>
-                <box flexDirection="row" gap={1} justifyContent="space-between">
-                  <text fg={theme.text}>Connect provider</text>
-                  <text fg={theme.textMuted}>/connect</text>
-                </box>
-              </box>
-            </box>
-          </Show>
-          <text>
-            <span style={{ fg: theme.textMuted }}>{directory().split("/").slice(0, -1).join("/")}/</span>
-            <span style={{ fg: theme.text }}>{directory().split("/").at(-1)}</span>
-          </text>
-          <text fg={theme.textMuted}>
-            <span style={{ fg: theme.success }}>•</span> <b>Open</b>
-            <span style={{ fg: theme.text }}>
-              <b>Code</b>
-            </span>{" "}
-            <span>{Installation.VERSION}</span>
-          </text>
+          <TuiPluginRuntime.Slot name="sidebar_footer" mode="single_winner" session_id={props.sessionID}>
+            <text fg={theme.textMuted}>
+              <span style={{ fg: theme.success }}>•</span> <b>Open</b>
+              <span style={{ fg: theme.text }}>
+                <b>Code</b>
+              </span>{" "}
+              <span>{Installation.VERSION}</span>
+            </text>
+          </TuiPluginRuntime.Slot>
         </box>
       </box>
     </Show>

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

@@ -6,6 +6,7 @@ import path from "path"
 import { fileURLToPath } from "url"
 import { UI } from "@/cli/ui"
 import { Log } from "@/util/log"
+import { errorMessage } from "@/util/error"
 import { withTimeout } from "@/util/timeout"
 import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
 import { Filesystem } from "@/util/filesystem"
@@ -145,7 +146,7 @@ export const TuiThreadCommand = cmd({
       const reload = () => {
         client.call("reload", undefined).catch((err) => {
           Log.Default.warn("worker reload failed", {
-            error: err instanceof Error ? err.message : String(err),
+            error: errorMessage(err),
           })
         })
       }
@@ -162,7 +163,7 @@ export const TuiThreadCommand = cmd({
         process.off("SIGUSR2", reload)
         await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
           Log.Default.warn("worker shutdown failed", {
-            error: error instanceof Error ? error.message : String(error),
+            error: errorMessage(error),
           })
         })
         worker.terminate()

+ 14 - 4
packages/opencode/src/cli/cmd/tui/ui/dialog.tsx

@@ -9,7 +9,7 @@ import { Selection } from "@tui/util/selection"
 
 export function Dialog(
   props: ParentProps<{
-    size?: "medium" | "large"
+    size?: "medium" | "large" | "xlarge"
     onClose: () => void
   }>,
 ) {
@@ -18,6 +18,11 @@ export function Dialog(
   const renderer = useRenderer()
 
   let dismiss = false
+  const width = () => {
+    if (props.size === "xlarge") return 116
+    if (props.size === "large") return 88
+    return 60
+  }
 
   return (
     <box
@@ -35,6 +40,7 @@ export function Dialog(
       height={dimensions().height}
       alignItems="center"
       position="absolute"
+      zIndex={3000}
       paddingTop={dimensions().height / 4}
       left={0}
       top={0}
@@ -45,7 +51,7 @@ export function Dialog(
           dismiss = false
           e.stopPropagation()
         }}
-        width={props.size === "large" ? 80 : 60}
+        width={width()}
         maxWidth={dimensions().width - 2}
         backgroundColor={theme.backgroundPanel}
         paddingTop={1}
@@ -62,7 +68,7 @@ function init() {
       element: JSX.Element
       onClose?: () => void
     }[],
-    size: "medium" as "medium" | "large",
+    size: "medium" as "medium" | "large" | "xlarge",
   })
 
   const renderer = useRenderer()
@@ -72,6 +78,9 @@ function init() {
     if (evt.defaultPrevented) return
     if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
     if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
+      if (renderer.getSelection()) {
+        renderer.clearSelection()
+      }
       const current = store.stack.at(-1)!
       current.onClose?.()
       setStore("stack", store.stack.slice(0, -1))
@@ -132,7 +141,7 @@ function init() {
     get size() {
       return store.size
     },
-    setSize(size: "medium" | "large") {
+    setSize(size: "medium" | "large" | "xlarge") {
       setStore("size", size)
     },
   }
@@ -151,6 +160,7 @@ export function DialogProvider(props: ParentProps) {
       {props.children}
       <box
         position="absolute"
+        zIndex={3000}
         onMouseDown={(evt) => {
           if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
           if (evt.button !== MouseButton.RIGHT) return

+ 2 - 13
packages/opencode/src/cli/error.ts

@@ -1,4 +1,5 @@
 import { ConfigMarkdown } from "@/config/markdown"
+import { errorFormat } from "@/util/error"
 import { Config } from "../config/config"
 import { MCP } from "../mcp"
 import { Provider } from "../provider/provider"
@@ -41,17 +42,5 @@ export function FormatError(input: unknown) {
 }
 
 export function FormatUnknownError(input: unknown): string {
-  if (input instanceof Error) {
-    return input.stack ?? `${input.name}: ${input.message}`
-  }
-
-  if (typeof input === "object" && input !== null) {
-    try {
-      return JSON.stringify(input, null, 2)
-    } catch {
-      return "Unexpected error (unserializable)"
-    }
-  }
-
-  return String(input)
+  return errorFormat(input)
 }

+ 108 - 65
packages/opencode/src/config/config.ts

@@ -30,20 +30,27 @@ import { GlobalBus } from "@/bus/global"
 import { Event } from "../server/event"
 import { Glob } from "../util/glob"
 import { PackageRegistry } from "@/bun/registry"
-import { proxied } from "@/util/proxied"
+import { online, proxied } from "@/util/network"
 import { iife } from "@/util/iife"
 import { Account } from "@/account"
+import { isRecord } from "@/util/record"
 import { ConfigPaths } from "./paths"
 import { Filesystem } from "@/util/filesystem"
 import { Process } from "@/util/process"
-import { Lock } from "@/util/lock"
 import { AppFileSystem } from "@/filesystem"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
 import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
+import { Flock } from "@/util/flock"
+import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
 
 export namespace Config {
   const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
+  const PluginOptions = z.record(z.string(), z.unknown())
+  export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
+
+  export type PluginOptions = z.infer<typeof PluginOptions>
+  export type PluginSpec = z.infer<typeof PluginSpec>
 
   const log = Log.create({ service: "config" })
 
@@ -78,34 +85,65 @@ export namespace Config {
     return merged
   }
 
-  export async function installDependencies(dir: string) {
+  export type InstallInput = {
+    signal?: AbortSignal
+    waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
+  }
+
+  export async function installDependencies(dir: string, input?: InstallInput) {
+    if (!(await needsInstall(dir))) return
+
+    await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
+      signal: input?.signal,
+      onWait: (tick) =>
+        input?.waitTick?.({
+          dir,
+          attempt: tick.attempt,
+          delay: tick.delay,
+          waited: tick.waited,
+        }),
+    })
+
+    input?.signal?.throwIfAborted()
+    if (!(await needsInstall(dir))) return
+
     const pkg = path.join(dir, "package.json")
-    const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
+    const target = Installation.isLocal() ? "*" : Installation.VERSION
 
     const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
       dependencies: {},
     }))
     json.dependencies = {
       ...json.dependencies,
-      "@opencode-ai/plugin": targetVersion,
+      "@opencode-ai/plugin": target,
     }
     await Filesystem.writeJson(pkg, json)
 
     const gitignore = path.join(dir, ".gitignore")
-    const hasGitIgnore = await Filesystem.exists(gitignore)
-    if (!hasGitIgnore)
+    const ignore = await Filesystem.exists(gitignore)
+    if (!ignore) {
       await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
+    }
+
+    // Bun can race cache writes on Windows when installs run in parallel across dirs.
+    // Serialize installs globally on win32, but keep parallel installs on other platforms.
+    await using __ =
+      process.platform === "win32"
+        ? await Flock.acquire("config-install:bun", {
+            signal: input?.signal,
+          })
+        : undefined
 
-    // Install any additional dependencies defined in the package.json
-    // This allows local plugins and custom tools to use external packages
-    using _ = await Lock.write("bun-install")
     await BunProc.run(
       [
         "install",
         // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
         ...(proxied() || process.env.CI ? ["--no-cache"] : []),
       ],
-      { cwd: dir },
+      {
+        cwd: dir,
+        abort: input?.signal,
+      },
     ).catch((err) => {
       if (err instanceof Process.RunFailedError) {
         const detail = {
@@ -149,8 +187,8 @@ export namespace Config {
       return false
     }
 
-    const nodeModules = path.join(dir, "node_modules")
-    if (!existsSync(nodeModules)) return true
+    const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
+    if (!existsSync(mod)) return true
 
     const pkg = path.join(dir, "package.json")
     const pkgExists = await Filesystem.exists(pkg)
@@ -163,8 +201,9 @@ export namespace Config {
 
     const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
     if (targetVersion === "latest") {
-      const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
-      if (!isOutdated) return false
+      if (!online()) return false
+      const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
+      if (!stale) return false
       log.info("Cached version is outdated, proceeding with install", {
         pkg: "@opencode-ai/plugin",
         cachedVersion: depVersion,
@@ -303,7 +342,7 @@ export namespace Config {
   }
 
   async function loadPlugin(dir: string) {
-    const plugins: string[] = []
+    const plugins: PluginSpec[] = []
 
     for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
       cwd: dir,
@@ -316,25 +355,44 @@ export namespace Config {
     return plugins
   }
 
-  /**
-   * Extracts a canonical plugin name from a plugin specifier.
-   * - For file:// URLs: extracts filename without extension
-   * - For npm packages: extracts package name without version
-   *
-   * @example
-   * getPluginName("file:///path/to/plugin/foo.js") // "foo"
-   * getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
-   * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
-   */
-  export function getPluginName(plugin: string): string {
-    if (plugin.startsWith("file://")) {
-      return path.parse(new URL(plugin).pathname).name
+  export function pluginSpecifier(plugin: PluginSpec): string {
+    return Array.isArray(plugin) ? plugin[0] : plugin
+  }
+
+  export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
+    return Array.isArray(plugin) ? plugin[1] : undefined
+  }
+
+  export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
+    const spec = pluginSpecifier(plugin)
+    if (!isPathPluginSpec(spec)) return plugin
+    if (spec.startsWith("file://")) {
+      const resolved = await resolvePathPluginTarget(spec).catch(() => spec)
+      if (Array.isArray(plugin)) return [resolved, plugin[1]]
+      return resolved
+    }
+    if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) {
+      const base = pathToFileURL(spec).href
+      const resolved = await resolvePathPluginTarget(base).catch(() => base)
+      if (Array.isArray(plugin)) return [resolved, plugin[1]]
+      return resolved
     }
-    const lastAt = plugin.lastIndexOf("@")
-    if (lastAt > 0) {
-      return plugin.substring(0, lastAt)
+    try {
+      const base = import.meta.resolve!(spec, configFilepath)
+      const resolved = await resolvePathPluginTarget(base).catch(() => base)
+      if (Array.isArray(plugin)) return [resolved, plugin[1]]
+      return resolved
+    } catch {
+      try {
+        const require = createRequire(configFilepath)
+        const base = pathToFileURL(require.resolve(spec)).href
+        const resolved = await resolvePathPluginTarget(base).catch(() => base)
+        if (Array.isArray(plugin)) return [resolved, plugin[1]]
+        return resolved
+      } catch {
+        return plugin
+      }
     }
-    return plugin
   }
 
   /**
@@ -348,17 +406,13 @@ export namespace Config {
    * Since plugins are added in low-to-high priority order,
    * we reverse, deduplicate (keeping first occurrence), then restore order.
    */
-  export function deduplicatePlugins(plugins: string[]): string[] {
-    // seenNames: canonical plugin names for duplicate detection
-    // e.g., "oh-my-opencode", "@scope/pkg"
+  export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
     const seenNames = new Set<string>()
-
-    // uniqueSpecifiers: full plugin specifiers to return
-    // e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
-    const uniqueSpecifiers: string[] = []
+    const uniqueSpecifiers: PluginSpec[] = []
 
     for (const specifier of plugins.toReversed()) {
-      const name = getPluginName(specifier)
+      const spec = pluginSpecifier(specifier)
+      const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
       if (!seenNames.has(name)) {
         seenNames.add(name)
         uniqueSpecifiers.push(specifier)
@@ -757,6 +811,7 @@ export namespace Config {
       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()
@@ -858,13 +913,13 @@ export namespace Config {
           ignore: z.array(z.string()).optional(),
         })
         .optional(),
-      plugin: z.string().array().optional(),
       snapshot: z
         .boolean()
         .optional()
         .describe(
           "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
         ),
+      plugin: PluginSpec.array().optional(),
       share: z
         .enum(["manual", "auto", "disabled"])
         .optional()
@@ -1070,10 +1125,6 @@ export namespace Config {
     return candidates[0]
   }
 
-  function isRecord(value: unknown): value is Record<string, unknown> {
-    return !!value && typeof value === "object" && !Array.isArray(value)
-  }
-
   function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
     if (!isRecord(patch)) {
       const edits = modify(input, path, patch, {
@@ -1189,19 +1240,9 @@ export namespace Config {
             }
             const data = parsed.data
             if (data.plugin && isFile) {
-              for (let i = 0; i < data.plugin.length; i++) {
-                const plugin = data.plugin[i]
-                try {
-                  data.plugin[i] = import.meta.resolve!(plugin, options.path)
-                } catch (e) {
-                  try {
-                    const require = createRequire(options.path)
-                    const resolvedPath = require.resolve(plugin)
-                    data.plugin[i] = pathToFileURL(resolvedPath).href
-                  } catch {
-                    // Ignore, plugin might be a generic string identifier like "mcp-server"
-                  }
-                }
+              const list = data.plugin
+              for (let i = 0; i < list.length; i++) {
+                list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
               }
             }
             return data
@@ -1326,12 +1367,14 @@ export namespace Config {
               }
             }
 
-            deps.push(
-              iife(async () => {
-                const shouldInstall = await needsInstall(dir)
-                if (shouldInstall) await installDependencies(dir)
-              }),
-            )
+            const dep = iife(async () => {
+              const stale = await needsInstall(dir)
+              if (stale) await installDependencies(dir)
+            })
+            void dep.catch((err) => {
+              log.warn("background dependency install failed", { dir, error: err })
+            })
+            deps.push(dep)
 
             result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
             result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))

+ 2 - 0
packages/opencode/src/config/tui-schema.ts

@@ -29,6 +29,8 @@ export const TuiInfo = z
     $schema: z.string().optional(),
     theme: z.string().optional(),
     keybinds: KeybindOverride.optional(),
+    plugin: Config.PluginSpec.array().optional(),
+    plugin_enabled: z.record(z.string(), z.boolean()).optional(),
   })
   .extend(TuiOptions.shape)
   .strict()

+ 121 - 27
packages/opencode/src/config/tui.ts

@@ -8,23 +8,101 @@ import { TuiInfo } from "./tui-schema"
 import { Instance } from "@/project/instance"
 import { Flag } from "@/flag/flag"
 import { Log } from "@/util/log"
+import { isRecord } from "@/util/record"
 import { Global } from "@/global"
+import { parsePluginSpecifier } from "@/plugin/shared"
 
 export namespace TuiConfig {
   const log = Log.create({ service: "tui.config" })
 
   export const Info = TuiInfo
 
-  export type Info = z.output<typeof Info>
+  export type PluginMeta = {
+    scope: "global" | "local"
+    source: string
+  }
+
+  type PluginEntry = {
+    item: Config.PluginSpec
+    meta: PluginMeta
+  }
+
+  type Acc = {
+    result: Info
+    entries: PluginEntry[]
+  }
+
+  export type Info = z.output<typeof Info> & {
+    plugin_meta?: Record<string, PluginMeta>
+  }
+
+  function pluginScope(file: string): PluginMeta["scope"] {
+    if (Instance.containsPath(file)) return "local"
+    return "global"
+  }
+
+  function dedupePlugins(list: PluginEntry[]) {
+    const seen = new Set<string>()
+    const result: PluginEntry[] = []
+    for (const item of list.toReversed()) {
+      const spec = Config.pluginSpecifier(item.item)
+      const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
+      if (seen.has(name)) continue
+      seen.add(name)
+      result.push(item)
+    }
+    return result.toReversed()
+  }
 
   function mergeInfo(target: Info, source: Info): Info {
-    return mergeDeep(target, source)
+    const merged = mergeDeep(target, source)
+    if (target.plugin && source.plugin) {
+      merged.plugin = [...target.plugin, ...source.plugin]
+    }
+    return merged
   }
 
   function customPath() {
     return Flag.OPENCODE_TUI_CONFIG
   }
 
+  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,
+    }
+  }
+
+  function installDeps(dir: string): Promise<void> {
+    return Config.installDependencies(dir)
+  }
+
+  async function mergeFile(acc: Acc, file: string) {
+    const data = await loadFile(file)
+    acc.result = mergeInfo(acc.result, data)
+    if (!data.plugin?.length) return
+
+    const scope = pluginScope(file)
+    for (const item of data.plugin) {
+      acc.entries.push({
+        item,
+        meta: {
+          scope,
+          source: file,
+        },
+      })
+    }
+  }
+
   const state = Instance.state(async () => {
     let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
       ? []
@@ -38,38 +116,55 @@ export namespace TuiConfig {
       ? []
       : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
 
-    let result: Info = {}
+    const acc: Acc = {
+      result: {},
+      entries: [],
+    }
 
     for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
-      result = mergeInfo(result, await loadFile(file))
+      await mergeFile(acc, file)
     }
 
     if (custom) {
-      result = mergeInfo(result, await loadFile(custom))
+      await mergeFile(acc, custom)
       log.debug("loaded custom tui config", { path: custom })
     }
 
     for (const file of projectFiles) {
-      result = mergeInfo(result, await loadFile(file))
+      await mergeFile(acc, file)
     }
 
     for (const dir of unique(directories)) {
       if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
       for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
-        result = mergeInfo(result, await loadFile(file))
+        await mergeFile(acc, file)
       }
     }
 
     if (existsSync(managed)) {
       for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
-        result = mergeInfo(result, await loadFile(file))
+        await mergeFile(acc, file)
       }
     }
 
-    result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
+    const merged = dedupePlugins(acc.entries)
+    acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
+    acc.result.plugin = merged.map((item) => item.item)
+    acc.result.plugin_meta = merged.length
+      ? Object.fromEntries(merged.map((item) => [Config.pluginSpecifier(item.item), item.meta]))
+      : undefined
+
+    const deps: Promise<void>[] = []
+    if (acc.result.plugin?.length) {
+      for (const dir of unique(directories)) {
+        if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
+        deps.push(installDeps(dir))
+      }
+    }
 
     return {
-      config: result,
+      config: acc.result,
+      deps,
     }
   })
 
@@ -77,6 +172,11 @@ export namespace TuiConfig {
     return state().then((x) => x.config)
   }
 
+  export async function waitForDependencies() {
+    const deps = await state().then((x) => x.deps)
+    await Promise.all(deps)
+  }
+
   async function loadFile(filepath: string): Promise<Info> {
     const text = await ConfigPaths.readFile(filepath)
     if (!text) return {}
@@ -87,25 +187,12 @@ export namespace TuiConfig {
   }
 
   async function load(text: string, configFilepath: string): Promise<Info> {
-    const data = await ConfigPaths.parseText(text, configFilepath, "empty")
-    if (!data || typeof data !== "object" || Array.isArray(data)) return {}
+    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 = (() => {
-      const copy = { ...(data as Record<string, unknown>) }
-      if (!("tui" in copy)) return copy
-      if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
-        delete copy.tui
-        return copy
-      }
-      const tui = copy.tui as Record<string, unknown>
-      delete copy.tui
-      return {
-        ...tui,
-        ...copy,
-      }
-    })()
+    const normalized = normalize(raw)
 
     const parsed = Info.safeParse(normalized)
     if (!parsed.success) {
@@ -113,6 +200,13 @@ export namespace TuiConfig {
       return {}
     }
 
-    return parsed.data
+    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
   }
 }

+ 25 - 0
packages/opencode/src/flag/flag.ts

@@ -14,13 +14,16 @@ export namespace Flag {
   export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
   export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
   export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
+  export declare const OPENCODE_PURE: boolean
   export declare const OPENCODE_TUI_CONFIG: string | undefined
   export declare const OPENCODE_CONFIG_DIR: string | undefined
+  export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
   export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
   export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
   export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
   export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
   export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
+  export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD")
   export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
   export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
   export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
@@ -117,6 +120,28 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
   configurable: false,
 })
 
+// Dynamic getter for OPENCODE_PURE
+// This must be evaluated at access time, not module load time,
+// because the CLI can set this flag at runtime
+Object.defineProperty(Flag, "OPENCODE_PURE", {
+  get() {
+    return truthy("OPENCODE_PURE")
+  },
+  enumerable: true,
+  configurable: false,
+})
+
+// Dynamic getter for OPENCODE_PLUGIN_META_FILE
+// This must be evaluated at access time, not module load time,
+// because tests and external tooling may set this env var at runtime
+Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", {
+  get() {
+    return process.env["OPENCODE_PLUGIN_META_FILE"]
+  },
+  enumerable: true,
+  configurable: false,
+})
+
 // Dynamic getter for OPENCODE_CLIENT
 // This must be evaluated at access time, not module load time,
 // because some commands override the client at runtime

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

@@ -33,16 +33,18 @@ import path from "path"
 import { Global } from "./global"
 import { JsonMigration } from "./storage/json-migration"
 import { Database } from "./storage/db"
+import { errorMessage } from "./util/error"
+import { PluginCommand } from "./cli/cmd/plug"
 
 process.on("unhandledRejection", (e) => {
   Log.Default.error("rejection", {
-    e: e instanceof Error ? e.message : e,
+    e: errorMessage(e),
   })
 })
 
 process.on("uncaughtException", (e) => {
   Log.Default.error("exception", {
-    e: e instanceof Error ? e.message : e,
+    e: errorMessage(e),
   })
 })
 
@@ -63,7 +65,15 @@ const cli = yargs(hideBin(process.argv))
     type: "string",
     choices: ["DEBUG", "INFO", "WARN", "ERROR"],
   })
+  .option("pure", {
+    describe: "run without external plugins",
+    type: "boolean",
+  })
   .middleware(async (opts) => {
+    if (opts.pure) {
+      process.env.OPENCODE_PURE = "1"
+    }
+
     await Log.init({
       print: process.argv.includes("--print-logs"),
       dev: Installation.isLocal(),
@@ -143,6 +153,7 @@ const cli = yargs(hideBin(process.argv))
   .command(GithubCommand)
   .command(PrCommand)
   .command(SessionCommand)
+  .command(PluginCommand)
   .command(DbCommand)
   .fail((msg, err) => {
     if (
@@ -194,7 +205,7 @@ try {
   if (formatted) UI.error(formatted)
   if (formatted === undefined) {
     UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
-    process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL)
+    process.stderr.write(errorMessage(e) + EOL)
   }
   process.exitCode = 1
 } finally {

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

@@ -1,9 +1,8 @@
-import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
+import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
 import { Config } from "../config/config"
 import { Bus } from "../bus"
 import { Log } from "../util/log"
 import { createOpencodeClient } from "@opencode-ai/sdk"
-import { BunProc } from "../bun"
 import { Flag } from "../flag/flag"
 import { CodexAuthPlugin } from "./codex"
 import { Session } from "../session"
@@ -14,6 +13,17 @@ import { PoeAuthPlugin } from "opencode-poe-auth"
 import { Effect, Layer, ServiceMap, Stream } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
+import { errorMessage } from "@/util/error"
+import { Installation } from "@/installation"
+import {
+  checkPluginCompatibility,
+  getDefaultPlugin,
+  isDeprecatedPlugin,
+  parsePluginSpecifier,
+  pluginSource,
+  resolvePluginEntrypoint,
+  resolvePluginTarget,
+} from "./shared"
 
 export namespace Plugin {
   const log = Log.create({ service: "plugin" })
@@ -22,6 +32,12 @@ export namespace Plugin {
     hooks: Hooks[]
   }
 
+  type Loaded = {
+    item: Config.PluginSpec
+    spec: string
+    mod: Record<string, unknown>
+  }
+
   // Hook names that follow the (input, output) => Promise<void> trigger pattern
   type TriggerName = {
     [K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
@@ -46,8 +62,115 @@ export namespace Plugin {
   // Built-in plugins that are directly imported (not installed from npm)
   const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
 
-  // Old npm package names for plugins that are now built-in — skip if users still have them in config
-  const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
+  function isServerPlugin(value: unknown): value is PluginInstance {
+    return typeof value === "function"
+  }
+
+  function getServerPlugin(value: unknown) {
+    if (isServerPlugin(value)) return value
+    if (!value || typeof value !== "object" || !("server" in value)) return
+    if (!isServerPlugin(value.server)) return
+    return value.server
+  }
+
+  function getLegacyPlugins(mod: Record<string, unknown>) {
+    const seen = new Set<unknown>()
+    const result: PluginInstance[] = []
+
+    for (const entry of Object.values(mod)) {
+      if (seen.has(entry)) continue
+      seen.add(entry)
+      const plugin = getServerPlugin(entry)
+      if (!plugin) throw new TypeError("Plugin export is not a function")
+      result.push(plugin)
+    }
+
+    return result
+  }
+
+  async function resolvePlugin(spec: string) {
+    const parsed = parsePluginSpecifier(spec)
+    const target = await resolvePluginTarget(spec, parsed).catch((err) => {
+      const cause = err instanceof Error ? err.cause : err
+      const detail = errorMessage(cause ?? err)
+      log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
+      Bus.publish(Session.Event.Error, {
+        error: new NamedError.Unknown({
+          message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
+        }).toObject(),
+      })
+      return ""
+    })
+    if (!target) return
+    return target
+  }
+
+  async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
+    const spec = Config.pluginSpecifier(item)
+    if (isDeprecatedPlugin(spec)) return
+    log.info("loading plugin", { path: spec })
+    const resolved = await resolvePlugin(spec)
+    if (!resolved) return
+
+    if (pluginSource(spec) === "npm") {
+      const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
+        .then(() => false)
+        .catch((err) => {
+          const message = errorMessage(err)
+          log.warn("plugin incompatible", { path: spec, error: message })
+          Bus.publish(Session.Event.Error, {
+            error: new NamedError.Unknown({
+              message: `Plugin ${spec} skipped: ${message}`,
+            }).toObject(),
+          })
+          return true
+        })
+      if (incompatible) return
+    }
+
+    const target = resolved
+    const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
+      const message = errorMessage(err)
+      log.error("failed to resolve plugin server entry", { path: spec, target, error: message })
+      Bus.publish(Session.Event.Error, {
+        error: new NamedError.Unknown({
+          message: `Failed to load plugin ${spec}: ${message}`,
+        }).toObject(),
+      })
+      return
+    })
+    if (!entry) return
+
+    const mod = await import(entry).catch((err) => {
+      const message = errorMessage(err)
+      log.error("failed to load plugin", { path: spec, target: entry, error: message })
+      Bus.publish(Session.Event.Error, {
+        error: new NamedError.Unknown({
+          message: `Failed to load plugin ${spec}: ${message}`,
+        }).toObject(),
+      })
+      return
+    })
+    if (!mod) return
+
+    return {
+      item,
+      spec,
+      mod,
+    }
+  }
+
+  async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
+    const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
+    if (plugin?.server) {
+      hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
+      return
+    }
+
+    for (const server of getLegacyPlugins(load.mod)) {
+      hooks.push(await server(input, Config.pluginOptions(load.item)))
+    }
+  }
 
   export const layer = Layer.effect(
     Service,
@@ -91,51 +214,27 @@ export namespace Plugin {
               if (init) hooks.push(init)
             }
 
-            let plugins = cfg.plugin ?? []
+            const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
+            if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
+              log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
+            }
             if (plugins.length) await Config.waitForDependencies()
 
-            for (let plugin of plugins) {
-              if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue
-              log.info("loading plugin", { path: plugin })
-              if (!plugin.startsWith("file://")) {
-                const idx = plugin.lastIndexOf("@")
-                const pkg = idx > 0 ? plugin.substring(0, idx) : plugin
-                const version = idx > 0 ? plugin.substring(idx + 1) : "latest"
-                plugin = await BunProc.install(pkg, version).catch((err) => {
-                  const cause = err instanceof Error ? err.cause : err
-                  const detail = cause instanceof Error ? cause.message : String(cause ?? err)
-                  log.error("failed to install plugin", { pkg, version, error: detail })
-                  Bus.publish(Session.Event.Error, {
-                    error: new NamedError.Unknown({
-                      message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
-                    }).toObject(),
-                  })
-                  return ""
-                })
-                if (!plugin) continue
-              }
+            const loaded = await Promise.all(plugins.map((item) => prepPlugin(item)))
+            for (const load of loaded) {
+              if (!load) continue
 
-              // Prevent duplicate initialization when plugins export the same function
-              // as both a named export and default export (e.g., `export const X` and `export default X`).
-              // Object.entries(mod) would return both entries pointing to the same function reference.
-              await import(plugin)
-                .then(async (mod) => {
-                  const seen = new Set<PluginInstance>()
-                  for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
-                    if (seen.has(fn)) continue
-                    seen.add(fn)
-                    hooks.push(await fn(input))
-                  }
-                })
-                .catch((err) => {
-                  const message = err instanceof Error ? err.message : String(err)
-                  log.error("failed to load plugin", { path: plugin, error: message })
-                  Bus.publish(Session.Event.Error, {
-                    error: new NamedError.Unknown({
-                      message: `Failed to load plugin ${plugin}: ${message}`,
-                    }).toObject(),
-                  })
+              // Keep plugin execution sequential so hook registration and execution
+              // order remains deterministic across plugin runs.
+              await applyPlugin(load, input, hooks).catch((err) => {
+                const message = errorMessage(err)
+                log.error("failed to load plugin", { path: load.spec, error: message })
+                Bus.publish(Session.Event.Error, {
+                  error: new NamedError.Unknown({
+                    message: `Failed to load plugin ${load.spec}: ${message}`,
+                  }).toObject(),
                 })
+              })
             }
 
             // Notify plugins of current config

+ 351 - 0
packages/opencode/src/plugin/install.ts

@@ -0,0 +1,351 @@
+import path from "path"
+import {
+  type ParseError as JsoncParseError,
+  applyEdits,
+  modify,
+  parse as parseJsonc,
+  printParseErrorCode,
+} from "jsonc-parser"
+
+import { ConfigPaths } from "@/config/paths"
+import { Global } from "@/global"
+import { Filesystem } from "@/util/filesystem"
+import { Flock } from "@/util/flock"
+
+import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
+
+type Mode = "noop" | "add" | "replace"
+type Kind = "server" | "tui"
+
+export type Target = {
+  kind: Kind
+  opts?: Record<string, unknown>
+}
+
+export type InstallDeps = {
+  resolve: (spec: string) => Promise<string>
+}
+
+export type PatchDeps = {
+  readText: (file: string) => Promise<string>
+  write: (file: string, text: string) => Promise<void>
+  exists: (file: string) => Promise<boolean>
+  files: (dir: string, name: "opencode" | "tui") => string[]
+}
+
+export type PatchInput = {
+  spec: string
+  targets: Target[]
+  force?: boolean
+  global?: boolean
+  vcs?: string
+  worktree: string
+  directory: string
+  config?: string
+}
+
+type Ok<T> = {
+  ok: true
+} & T
+
+type Err<C extends string, T> = {
+  ok: false
+  code: C
+} & T
+
+export type InstallResult = Ok<{ target: string }> | Err<"install_failed", { error: unknown }>
+
+export type ManifestResult =
+  | Ok<{ targets: Target[] }>
+  | Err<"manifest_read_failed", { file: string; error: unknown }>
+  | Err<"manifest_no_targets", { file: string }>
+
+export type PatchItem = {
+  kind: Kind
+  mode: Mode
+  file: string
+}
+
+type PatchErr =
+  | Err<"invalid_json", { kind: Kind; file: string; line: number; col: number; parse: string }>
+  | Err<"patch_failed", { kind: Kind; error: unknown }>
+
+type PatchOne = Ok<{ item: PatchItem }> | PatchErr
+
+export type PatchResult = Ok<{ dir: string; items: PatchItem[] }> | (PatchErr & { dir: string })
+
+const defaultInstallDeps: InstallDeps = {
+  resolve: (spec) => resolvePluginTarget(spec),
+}
+
+const defaultPatchDeps: PatchDeps = {
+  readText: (file) => Filesystem.readText(file),
+  write: async (file, text) => {
+    await Filesystem.write(file, text)
+  },
+  exists: (file) => Filesystem.exists(file),
+  files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
+}
+
+function pluginSpec(item: unknown) {
+  if (typeof item === "string") return item
+  if (!Array.isArray(item)) return
+  if (typeof item[0] !== "string") return
+  return item[0]
+}
+
+function parseTarget(item: unknown): Target | undefined {
+  if (item === "server" || item === "tui") return { kind: item }
+  if (!Array.isArray(item)) return
+  if (item[0] !== "server" && item[0] !== "tui") return
+  if (item.length < 2) return { kind: item[0] }
+  const opt = item[1]
+  if (!opt || typeof opt !== "object" || Array.isArray(opt)) return { kind: item[0] }
+  return {
+    kind: item[0],
+    opts: opt,
+  }
+}
+
+function parseTargets(raw: unknown) {
+  if (!Array.isArray(raw)) return []
+  const map = new Map<Kind, Target>()
+  for (const item of raw) {
+    const hit = parseTarget(item)
+    if (!hit) continue
+    map.set(hit.kind, hit)
+  }
+  return [...map.values()]
+}
+
+function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } {
+  const pkg = parsePluginSpecifier(spec).pkg
+  const rows = list.map((item, i) => ({
+    item,
+    i,
+    spec: pluginSpec(item),
+  }))
+  const dup = rows.filter((item) => {
+    if (!item.spec) return false
+    if (item.spec === spec) return true
+    if (item.spec.startsWith("file://")) return false
+    return parsePluginSpecifier(item.spec).pkg === pkg
+  })
+
+  if (!dup.length) {
+    return {
+      mode: "add",
+      list: [...list, next],
+    }
+  }
+
+  if (!force) {
+    return {
+      mode: "noop",
+      list,
+    }
+  }
+
+  const keep = dup[0]
+  if (!keep) {
+    return {
+      mode: "noop",
+      list,
+    }
+  }
+
+  if (dup.length === 1 && keep.spec === spec) {
+    return {
+      mode: "noop",
+      list,
+    }
+  }
+
+  const idx = new Set(dup.map((item) => item.i))
+  return {
+    mode: "replace",
+    list: rows.flatMap((row) => {
+      if (!idx.has(row.i)) return [row.item]
+      if (row.i !== keep.i) return []
+      if (typeof row.item === "string") return [next]
+      if (Array.isArray(row.item) && typeof row.item[0] === "string") {
+        return [[spec, ...row.item.slice(1)]]
+      }
+      return [row.item]
+    }),
+  }
+}
+
+export async function installPlugin(spec: string, dep: InstallDeps = defaultInstallDeps): Promise<InstallResult> {
+  const target = await dep.resolve(spec).then(
+    (item) => ({
+      ok: true as const,
+      item,
+    }),
+    (error: unknown) => ({
+      ok: false as const,
+      error,
+    }),
+  )
+  if (!target.ok) {
+    return {
+      ok: false,
+      code: "install_failed",
+      error: target.error,
+    }
+  }
+  return {
+    ok: true,
+    target: target.item,
+  }
+}
+
+export async function readPluginManifest(target: string): Promise<ManifestResult> {
+  const pkg = await readPluginPackage(target).then(
+    (item) => ({
+      ok: true as const,
+      item,
+    }),
+    (error: unknown) => ({
+      ok: false as const,
+      error,
+    }),
+  )
+  if (!pkg.ok) {
+    return {
+      ok: false,
+      code: "manifest_read_failed",
+      file: target,
+      error: pkg.error,
+    }
+  }
+
+  const targets = parseTargets(pkg.item.json["oc-plugin"])
+  if (!targets.length) {
+    return {
+      ok: false,
+      code: "manifest_no_targets",
+      file: pkg.item.pkg,
+    }
+  }
+
+  return {
+    ok: true,
+    targets,
+  }
+}
+
+function patchDir(input: PatchInput) {
+  if (input.global) return input.config ?? Global.Path.config
+  const git = input.vcs === "git" && input.worktree !== "/"
+  const root = git ? input.worktree : input.directory
+  return path.join(root, ".opencode")
+}
+
+function patchName(kind: Kind): "opencode" | "tui" {
+  if (kind === "server") return "opencode"
+  return "tui"
+}
+
+async function patchOne(dir: string, target: Target, spec: string, force: boolean, dep: PatchDeps): Promise<PatchOne> {
+  const name = patchName(target.kind)
+  await using _ = await Flock.acquire(`plug-config:${Filesystem.resolve(path.join(dir, name))}`)
+
+  const files = dep.files(dir, name)
+  let cfg = files[0]
+  for (const file of files) {
+    if (!(await dep.exists(file))) continue
+    cfg = file
+    break
+  }
+
+  const src = await dep.readText(cfg).catch((err: NodeJS.ErrnoException) => {
+    if (err.code === "ENOENT") return "{}"
+    return err
+  })
+  if (src instanceof Error) {
+    return {
+      ok: false,
+      code: "patch_failed",
+      kind: target.kind,
+      error: src,
+    }
+  }
+  const text = src.trim() ? src : "{}"
+
+  const errs: JsoncParseError[] = []
+  const data = parseJsonc(text, errs, { allowTrailingComma: true })
+  if (errs.length) {
+    const err = errs[0]
+    const lines = text.substring(0, err.offset).split("\n")
+    return {
+      ok: false,
+      code: "invalid_json",
+      kind: target.kind,
+      file: cfg,
+      line: lines.length,
+      col: lines[lines.length - 1].length + 1,
+      parse: printParseErrorCode(err.error),
+    }
+  }
+
+  const list: unknown[] =
+    data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : []
+  const item = target.opts ? [spec, target.opts] : spec
+  const out = patchPluginList(list, spec, item, force)
+  if (out.mode === "noop") {
+    return {
+      ok: true,
+      item: {
+        kind: target.kind,
+        mode: out.mode,
+        file: cfg,
+      },
+    }
+  }
+
+  const edits = modify(text, ["plugin"], out.list, {
+    formattingOptions: {
+      tabSize: 2,
+      insertSpaces: true,
+    },
+  })
+  const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error)
+  if (write instanceof Error) {
+    return {
+      ok: false,
+      code: "patch_failed",
+      kind: target.kind,
+      error: write,
+    }
+  }
+
+  return {
+    ok: true,
+    item: {
+      kind: target.kind,
+      mode: out.mode,
+      file: cfg,
+    },
+  }
+}
+
+export async function patchPluginConfig(input: PatchInput, dep: PatchDeps = defaultPatchDeps): Promise<PatchResult> {
+  const dir = patchDir(input)
+  const items: PatchItem[] = []
+  for (const target of input.targets) {
+    const hit = await patchOne(dir, target, input.spec, Boolean(input.force), dep)
+    if (!hit.ok) {
+      return {
+        ...hit,
+        dir,
+      }
+    }
+    items.push(hit.item)
+  }
+  return {
+    ok: true,
+    dir,
+    items,
+  }
+}

+ 165 - 0
packages/opencode/src/plugin/meta.ts

@@ -0,0 +1,165 @@
+import path from "path"
+import { fileURLToPath } from "url"
+
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+import { Filesystem } from "@/util/filesystem"
+import { Flock } from "@/util/flock"
+
+import { parsePluginSpecifier, pluginSource } from "./shared"
+
+export namespace PluginMeta {
+  type Source = "file" | "npm"
+
+  export type Entry = {
+    id: string
+    source: Source
+    spec: string
+    target: string
+    requested?: string
+    version?: string
+    modified?: number
+    first_time: number
+    last_time: number
+    time_changed: number
+    load_count: number
+    fingerprint: string
+  }
+
+  export type State = "first" | "updated" | "same"
+
+  export type Touch = {
+    spec: string
+    target: string
+    id: string
+  }
+
+  type Store = Record<string, Entry>
+  type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
+  type Row = Touch & { core: Core }
+
+  function storePath() {
+    return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
+  }
+
+  function lock(file: string) {
+    return `plugin-meta:${file}`
+  }
+
+  function fileTarget(spec: string, target: string) {
+    if (spec.startsWith("file://")) return fileURLToPath(spec)
+    if (target.startsWith("file://")) return fileURLToPath(target)
+    return
+  }
+
+  function modifiedAt(file: string) {
+    const stat = Filesystem.stat(file)
+    if (!stat) return
+    const value = stat.mtimeMs
+    return Math.floor(typeof value === "bigint" ? Number(value) : value)
+  }
+
+  function resolvedTarget(target: string) {
+    if (target.startsWith("file://")) return fileURLToPath(target)
+    return target
+  }
+
+  async function npmVersion(target: string) {
+    const resolved = resolvedTarget(target)
+    const stat = Filesystem.stat(resolved)
+    const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
+    return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
+      .then((item) => item.version)
+      .catch(() => undefined)
+  }
+
+  async function entryCore(item: Touch): Promise<Core> {
+    const spec = item.spec
+    const target = item.target
+    const source = pluginSource(spec)
+    if (source === "file") {
+      const file = fileTarget(spec, target)
+      return {
+        id: item.id,
+        source,
+        spec,
+        target,
+        modified: file ? modifiedAt(file) : undefined,
+      }
+    }
+
+    return {
+      id: item.id,
+      source,
+      spec,
+      target,
+      requested: parsePluginSpecifier(spec).version,
+      version: await npmVersion(target),
+    }
+  }
+
+  function fingerprint(value: Core) {
+    if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
+    return [value.target, value.requested ?? "", value.version ?? ""].join("|")
+  }
+
+  async function read(file: string): Promise<Store> {
+    return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
+  }
+
+  async function row(item: Touch): Promise<Row> {
+    return {
+      ...item,
+      core: await entryCore(item),
+    }
+  }
+
+  function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
+    const entry: Entry = {
+      ...core,
+      first_time: prev?.first_time ?? now,
+      last_time: now,
+      time_changed: prev?.time_changed ?? now,
+      load_count: (prev?.load_count ?? 0) + 1,
+      fingerprint: fingerprint(core),
+    }
+    const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
+    if (state === "updated") entry.time_changed = now
+    return {
+      state,
+      entry,
+    }
+  }
+
+  export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
+    if (!items.length) return []
+    const file = storePath()
+    const rows = await Promise.all(items.map((item) => row(item)))
+
+    return Flock.withLock(lock(file), async () => {
+      const store = await read(file)
+      const now = Date.now()
+      const out: Array<{ state: State; entry: Entry }> = []
+      for (const item of rows) {
+        const hit = next(store[item.id], item.core, now)
+        store[item.id] = hit.entry
+        out.push(hit)
+      }
+      await Filesystem.writeJson(file, store)
+      return out
+    })
+  }
+
+  export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> {
+    return touchMany([{ spec, target, id }]).then((item) => {
+      const hit = item[0]
+      if (hit) return hit
+      throw new Error("Failed to touch plugin metadata.")
+    })
+  }
+
+  export async function list(): Promise<Store> {
+    const file = storePath()
+    return Flock.withLock(lock(file), async () => read(file))
+  }
+}

+ 149 - 0
packages/opencode/src/plugin/shared.ts

@@ -0,0 +1,149 @@
+import path from "path"
+import { fileURLToPath, pathToFileURL } from "url"
+import semver from "semver"
+import { BunProc } from "@/bun"
+import { Filesystem } from "@/util/filesystem"
+import { isRecord } from "@/util/record"
+
+// Old npm package names for plugins that are now built-in
+export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
+
+export function isDeprecatedPlugin(spec: string) {
+  return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg))
+}
+
+export function parsePluginSpecifier(spec: string) {
+  const lastAt = spec.lastIndexOf("@")
+  const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
+  const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
+  return { pkg, version }
+}
+
+export type PluginSource = "file" | "npm"
+export type PluginKind = "server" | "tui"
+
+export function pluginSource(spec: string): PluginSource {
+  return spec.startsWith("file://") ? "file" : "npm"
+}
+
+function hasEntrypoint(json: Record<string, unknown>, kind: PluginKind) {
+  if (!isRecord(json.exports)) return false
+  return `./${kind}` in json.exports
+}
+
+function resolveExportPath(raw: string, dir: string) {
+  if (raw.startsWith("./") || raw.startsWith("../")) return path.resolve(dir, raw)
+  if (raw.startsWith("file://")) return fileURLToPath(raw)
+  return raw
+}
+
+function extractExportValue(value: unknown): string | undefined {
+  if (typeof value === "string") return value
+  if (!isRecord(value)) return undefined
+  for (const key of ["import", "default"]) {
+    const nested = value[key]
+    if (typeof nested === "string") return nested
+  }
+  return undefined
+}
+
+export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) {
+  const pkg = await readPluginPackage(target).catch(() => undefined)
+  if (!pkg) return target
+  if (!hasEntrypoint(pkg.json, kind)) return target
+
+  const exports = pkg.json.exports
+  if (!isRecord(exports)) return target
+  const raw = extractExportValue(exports[`./${kind}`])
+  if (!raw) return target
+
+  const resolved = resolveExportPath(raw, pkg.dir)
+  const root = Filesystem.resolve(pkg.dir)
+  const next = Filesystem.resolve(resolved)
+  if (!Filesystem.contains(root, next)) {
+    throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
+  }
+
+  return pathToFileURL(next).href
+}
+
+export function isPathPluginSpec(spec: string) {
+  return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
+}
+
+export async function resolvePathPluginTarget(spec: string) {
+  const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
+  const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
+  const stat = await Filesystem.stat(file)
+  if (!stat?.isDirectory()) {
+    if (spec.startsWith("file://")) return spec
+    return pathToFileURL(file).href
+  }
+
+  const pkg = await Filesystem.readJson<Record<string, unknown>>(path.join(file, "package.json")).catch(() => undefined)
+  if (!pkg) throw new Error(`Plugin directory ${file} is missing package.json`)
+  if (typeof pkg.main !== "string" || !pkg.main.trim()) {
+    throw new Error(`Plugin directory ${file} must define package.json main`)
+  }
+  return pathToFileURL(path.resolve(file, pkg.main)).href
+}
+
+export async function checkPluginCompatibility(target: string, opencodeVersion: string) {
+  if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return
+  const pkg = await readPluginPackage(target).catch(() => undefined)
+  if (!pkg) return
+  const engines = pkg.json.engines
+  if (!isRecord(engines)) return
+  const range = engines.opencode
+  if (typeof range !== "string") return
+  if (!semver.satisfies(opencodeVersion, range)) {
+    throw new Error(`Plugin requires opencode ${range} but running ${opencodeVersion}`)
+  }
+}
+
+export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
+  if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
+  return BunProc.install(parsed.pkg, parsed.version)
+}
+
+export async function readPluginPackage(target: string) {
+  const file = target.startsWith("file://") ? fileURLToPath(target) : target
+  const stat = await Filesystem.stat(file)
+  const dir = stat?.isDirectory() ? file : path.dirname(file)
+  const pkg = path.join(dir, "package.json")
+  const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
+  return { dir, pkg, json }
+}
+
+export function readPluginId(id: unknown, spec: string) {
+  if (id === undefined) return
+  if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
+  const value = id.trim()
+  if (!value) throw new TypeError(`Plugin ${spec} has an empty id`)
+  return value
+}
+
+export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
+  if (source === "file") {
+    if (id) return id
+    throw new TypeError(`Path plugin ${spec} must export id`)
+  }
+  if (id) return id
+  const pkg = await readPluginPackage(target)
+  if (typeof pkg.json.name !== "string" || !pkg.json.name.trim()) {
+    throw new TypeError(`Plugin package ${pkg.pkg} is missing name`)
+  }
+  return pkg.json.name.trim()
+}
+
+export function getDefaultPlugin(mod: Record<string, unknown>) {
+  // A single default object keeps v1 detection explicit and avoids scanning exports.
+  const value = mod.default
+  if (!isRecord(value)) return
+  const server = "server" in value ? value.server : undefined
+  const tui = "tui" in value ? value.tui : undefined
+  if (server !== undefined && typeof server !== "function") return
+  if (tui !== undefined && typeof tui !== "function") return
+  if (server === undefined && tui === undefined) return
+  return value
+}

+ 3 - 3
packages/opencode/src/provider/auth.ts

@@ -1,4 +1,4 @@
-import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
+import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
 import { NamedError } from "@opencode-ai/util/error"
 import { Auth } from "@/auth"
 import { InstanceState } from "@/effect/instance-state"
@@ -106,7 +106,7 @@ export namespace ProviderAuth {
 
   interface State {
     hooks: Record<ProviderID, Hook>
-    pending: Map<ProviderID, AuthOuathResult>
+    pending: Map<ProviderID, AuthOAuthResult>
   }
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
@@ -127,7 +127,7 @@ export namespace ProviderAuth {
                     : Result.failVoid,
                 ),
               ),
-              pending: new Map<ProviderID, AuthOuathResult>(),
+              pending: new Map<ProviderID, AuthOAuthResult>(),
             }
           }),
         ),

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

@@ -11,6 +11,7 @@ import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/stora
 import { MessageTable, PartTable, SessionTable } from "./session.sql"
 import { ProviderError } from "@/provider/error"
 import { iife } from "@/util/iife"
+import { errorMessage } from "@/util/error"
 import type { SystemError } from "bun"
 import type { Provider } from "@/provider/provider"
 import { ModelID, ProviderID } from "@/provider/schema"
@@ -990,7 +991,7 @@ export namespace MessageV2 {
           { cause: e },
         ).toObject()
       case e instanceof Error:
-        return new NamedError.Unknown({ message: e instanceof Error ? e.message : String(e) }, { cause: e }).toObject()
+        return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
       default:
         try {
           const parsed = ProviderError.parseStreamError(e)

+ 2 - 1
packages/opencode/src/tool/batch.ts

@@ -1,6 +1,7 @@
 import z from "zod"
 import { Tool } from "./tool"
 import { ProviderID, ModelID } from "../provider/schema"
+import { errorMessage } from "../util/error"
 import DESCRIPTION from "./batch.txt"
 
 const DISALLOWED = new Set(["batch"])
@@ -118,7 +119,7 @@ export const BatchTool = Tool.define("batch", async () => {
             state: {
               status: "error",
               input: call.parameters,
-              error: error instanceof Error ? error.message : String(error),
+              error: errorMessage(error),
               time: {
                 start: callStartTime,
                 end: Date.now(),

+ 77 - 0
packages/opencode/src/util/error.ts

@@ -0,0 +1,77 @@
+import { isRecord } from "./record"
+
+export function errorFormat(error: unknown): string {
+  if (error instanceof Error) {
+    return error.stack ?? `${error.name}: ${error.message}`
+  }
+
+  if (typeof error === "object" && error !== null) {
+    try {
+      return JSON.stringify(error, null, 2)
+    } catch {
+      return "Unexpected error (unserializable)"
+    }
+  }
+
+  return String(error)
+}
+
+export function errorMessage(error: unknown): string {
+  if (error instanceof Error) {
+    if (error.message) return error.message
+    if (error.name) return error.name
+  }
+
+  if (isRecord(error) && typeof error.message === "string" && error.message) {
+    return error.message
+  }
+
+  const text = String(error)
+  if (text && text !== "[object Object]") return text
+
+  const formatted = errorFormat(error)
+  if (formatted && formatted !== "{}") return formatted
+  return "unknown error"
+}
+
+export function errorData(error: unknown) {
+  if (error instanceof Error) {
+    return {
+      type: error.name,
+      message: errorMessage(error),
+      stack: error.stack,
+      cause: error.cause === undefined ? undefined : errorFormat(error.cause),
+      formatted: errorFormatted(error),
+    }
+  }
+
+  if (!isRecord(error)) {
+    return {
+      type: typeof error,
+      message: errorMessage(error),
+      formatted: errorFormatted(error),
+    }
+  }
+
+  const data = Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
+    const value = error[key]
+    if (value === undefined) return acc
+    if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
+      acc[key] = value
+      return acc
+    }
+    acc[key] = value instanceof Error ? value.message : String(value)
+    return acc
+  }, {})
+
+  if (typeof data.message !== "string") data.message = errorMessage(error)
+  if (typeof data.type !== "string") data.type = error.constructor?.name
+  data.formatted = errorFormatted(error)
+  return data
+}
+
+function errorFormatted(error: unknown) {
+  const formatted = errorFormat(error)
+  if (formatted !== "{}") return formatted
+  return String(error)
+}

+ 333 - 0
packages/opencode/src/util/flock.ts

@@ -0,0 +1,333 @@
+import path from "path"
+import os from "os"
+import { randomBytes, randomUUID } from "crypto"
+import { mkdir, readFile, rm, stat, utimes, writeFile } from "fs/promises"
+import { Global } from "@/global"
+import { Hash } from "@/util/hash"
+
+export namespace Flock {
+  const root = path.join(Global.Path.state, "locks")
+  // Defaults for callers that do not provide timing options.
+  const defaultOpts = {
+    staleMs: 60_000,
+    timeoutMs: 5 * 60_000,
+    baseDelayMs: 100,
+    maxDelayMs: 2_000,
+  }
+
+  export interface WaitEvent {
+    key: string
+    attempt: number
+    delay: number
+    waited: number
+  }
+
+  export type Wait = (input: WaitEvent) => void | Promise<void>
+
+  export interface Options {
+    dir?: string
+    signal?: AbortSignal
+    staleMs?: number
+    timeoutMs?: number
+    baseDelayMs?: number
+    maxDelayMs?: number
+    onWait?: Wait
+  }
+
+  type Opts = {
+    staleMs: number
+    timeoutMs: number
+    baseDelayMs: number
+    maxDelayMs: number
+  }
+
+  type Owned = {
+    acquired: true
+    startHeartbeat: (intervalMs?: number) => void
+    release: () => Promise<void>
+  }
+
+  export interface Lease {
+    release: () => Promise<void>
+    [Symbol.asyncDispose]: () => Promise<void>
+  }
+
+  function code(err: unknown) {
+    if (typeof err !== "object" || err === null || !("code" in err)) return
+    const value = err.code
+    if (typeof value !== "string") return
+    return value
+  }
+
+  function sleep(ms: number, signal?: AbortSignal) {
+    return new Promise<void>((resolve, reject) => {
+      if (signal?.aborted) {
+        reject(signal.reason ?? new Error("Aborted"))
+        return
+      }
+
+      let timer: NodeJS.Timeout | undefined
+
+      const done = () => {
+        signal?.removeEventListener("abort", abort)
+        resolve()
+      }
+
+      const abort = () => {
+        if (timer) {
+          clearTimeout(timer)
+        }
+        signal?.removeEventListener("abort", abort)
+        reject(signal?.reason ?? new Error("Aborted"))
+      }
+
+      signal?.addEventListener("abort", abort, { once: true })
+      timer = setTimeout(done, ms)
+    })
+  }
+
+  function jitter(ms: number) {
+    const j = Math.floor(ms * 0.3)
+    const d = Math.floor(Math.random() * (2 * j + 1)) - j
+    return Math.max(0, ms + d)
+  }
+
+  function mono() {
+    return performance.now()
+  }
+
+  function wall() {
+    return performance.timeOrigin + mono()
+  }
+
+  async function stats(file: string) {
+    try {
+      return await stat(file)
+    } catch (err) {
+      const errCode = code(err)
+      if (errCode === "ENOENT" || errCode === "ENOTDIR") return
+      throw err
+    }
+  }
+
+  async function stale(lockDir: string, heartbeatPath: string, metaPath: string, staleMs: number) {
+    // Stale detection allows automatic recovery after crashed owners.
+    const now = wall()
+    const heartbeat = await stats(heartbeatPath)
+    if (heartbeat) {
+      return now - heartbeat.mtimeMs > staleMs
+    }
+
+    const meta = await stats(metaPath)
+    if (meta) {
+      return now - meta.mtimeMs > staleMs
+    }
+
+    const dir = await stats(lockDir)
+    if (!dir) {
+      return false
+    }
+
+    return now - dir.mtimeMs > staleMs
+  }
+
+  async function tryAcquireLockDir(lockDir: string, opts: Opts): Promise<Owned | { acquired: false }> {
+    const token = randomUUID?.() ?? randomBytes(16).toString("hex")
+    const metaPath = path.join(lockDir, "meta.json")
+    const heartbeatPath = path.join(lockDir, "heartbeat")
+
+    try {
+      await mkdir(lockDir, { mode: 0o700 })
+    } catch (err) {
+      if (code(err) !== "EEXIST") {
+        throw err
+      }
+
+      if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
+        return { acquired: false }
+      }
+
+      const breakerPath = lockDir + ".breaker"
+      try {
+        await mkdir(breakerPath, { mode: 0o700 })
+      } catch (claimErr) {
+        const errCode = code(claimErr)
+        if (errCode === "EEXIST") {
+          const breaker = await stats(breakerPath)
+          if (breaker && wall() - breaker.mtimeMs > opts.staleMs) {
+            await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
+          }
+          return { acquired: false }
+        }
+
+        if (errCode === "ENOENT" || errCode === "ENOTDIR") {
+          return { acquired: false }
+        }
+
+        throw claimErr
+      }
+
+      try {
+        // Breaker ownership ensures only one contender performs stale cleanup.
+        if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
+          return { acquired: false }
+        }
+
+        await rm(lockDir, { recursive: true, force: true })
+
+        try {
+          await mkdir(lockDir, { mode: 0o700 })
+        } catch (retryErr) {
+          const errCode = code(retryErr)
+          if (errCode === "EEXIST" || errCode === "ENOTEMPTY") {
+            return { acquired: false }
+          }
+          throw retryErr
+        }
+      } finally {
+        await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
+      }
+    }
+
+    const meta = {
+      token,
+      pid: process.pid,
+      hostname: os.hostname(),
+      createdAt: new Date().toISOString(),
+    }
+
+    await writeFile(heartbeatPath, "", { flag: "wx" }).catch(async () => {
+      await rm(lockDir, { recursive: true, force: true })
+      throw new Error("Lock acquired but heartbeat already existed (possible compromise).")
+    })
+
+    await writeFile(metaPath, JSON.stringify(meta, null, 2), { flag: "wx" }).catch(async () => {
+      await rm(lockDir, { recursive: true, force: true })
+      throw new Error("Lock acquired but meta.json already existed (possible compromise).")
+    })
+
+    let timer: NodeJS.Timeout | undefined
+
+    const startHeartbeat = (intervalMs = Math.max(100, Math.floor(opts.staleMs / 3))) => {
+      if (timer) return
+      // Heartbeat prevents long critical sections from being evicted as stale.
+      timer = setInterval(() => {
+        const t = new Date()
+        void utimes(heartbeatPath, t, t).catch(() => undefined)
+      }, intervalMs)
+      timer.unref?.()
+    }
+
+    const release = async () => {
+      if (timer) {
+        clearInterval(timer)
+        timer = undefined
+      }
+
+      const current = await readFile(metaPath, "utf8")
+        .then((raw) => {
+          const parsed = JSON.parse(raw)
+          if (!parsed || typeof parsed !== "object") return {}
+          return {
+            token: "token" in parsed && typeof parsed.token === "string" ? parsed.token : undefined,
+          }
+        })
+        .catch((err) => {
+          const errCode = code(err)
+          if (errCode === "ENOENT" || errCode === "ENOTDIR") {
+            throw new Error("Refusing to release: lock is compromised (metadata missing).")
+          }
+          if (err instanceof SyntaxError) {
+            throw new Error("Refusing to release: lock is compromised (metadata invalid).")
+          }
+          throw err
+        })
+      // Token check prevents deleting a lock that was re-acquired by another process.
+      if (current.token !== token) {
+        throw new Error("Refusing to release: lock token mismatch (not the owner).")
+      }
+
+      await rm(lockDir, { recursive: true, force: true })
+    }
+
+    return {
+      acquired: true,
+      startHeartbeat,
+      release,
+    }
+  }
+
+  async function acquireLockDir(
+    lockDir: string,
+    input: { key: string; onWait?: Wait; signal?: AbortSignal },
+    opts: Opts,
+  ) {
+    const stop = mono() + opts.timeoutMs
+    let attempt = 0
+    let waited = 0
+    let delay = opts.baseDelayMs
+
+    while (true) {
+      input.signal?.throwIfAborted()
+
+      const res = await tryAcquireLockDir(lockDir, opts)
+      if (res.acquired) {
+        return res
+      }
+
+      if (mono() > stop) {
+        throw new Error(`Timed out waiting for lock: ${input.key}`)
+      }
+
+      attempt += 1
+      const ms = jitter(delay)
+      await input.onWait?.({
+        key: input.key,
+        attempt,
+        delay: ms,
+        waited,
+      })
+      await sleep(ms, input.signal)
+      waited += ms
+      delay = Math.min(opts.maxDelayMs, Math.floor(delay * 1.7))
+    }
+  }
+
+  export async function acquire(key: string, input: Options = {}): Promise<Lease> {
+    input.signal?.throwIfAborted()
+    const cfg: Opts = {
+      staleMs: input.staleMs ?? defaultOpts.staleMs,
+      timeoutMs: input.timeoutMs ?? defaultOpts.timeoutMs,
+      baseDelayMs: input.baseDelayMs ?? defaultOpts.baseDelayMs,
+      maxDelayMs: input.maxDelayMs ?? defaultOpts.maxDelayMs,
+    }
+    const dir = input.dir ?? root
+
+    await mkdir(dir, { recursive: true })
+    const lockfile = path.join(dir, Hash.fast(key) + ".lock")
+    const lock = await acquireLockDir(
+      lockfile,
+      {
+        key,
+        onWait: input.onWait,
+        signal: input.signal,
+      },
+      cfg,
+    )
+    lock.startHeartbeat()
+
+    const release = () => lock.release()
+    return {
+      release,
+      [Symbol.asyncDispose]() {
+        return release()
+      },
+    }
+  }
+
+  export async function withLock<T>(key: string, fn: () => Promise<T>, input: Options = {}) {
+    await using _ = await acquire(key, input)
+    input.signal?.throwIfAborted()
+    return await fn()
+  }
+}

+ 6 - 0
packages/opencode/src/util/proxied.ts → packages/opencode/src/util/network.ts

@@ -1,3 +1,9 @@
+export function online() {
+  const nav = globalThis.navigator
+  if (!nav || typeof nav.onLine !== "boolean") return true
+  return nav.onLine
+}
+
 export function proxied() {
   return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
 }

+ 2 - 1
packages/opencode/src/util/process.ts

@@ -1,6 +1,7 @@
 import { type ChildProcess } from "child_process"
 import launch from "cross-spawn"
 import { buffer } from "node:stream/consumers"
+import { errorMessage } from "./error"
 
 export namespace Process {
   export type Stdio = "inherit" | "pipe" | "ignore"
@@ -136,7 +137,7 @@ export namespace Process {
         return {
           code: 1,
           stdout: Buffer.alloc(0),
-          stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
+          stderr: Buffer.from(errorMessage(err)),
         }
       })
     if (out.code === 0 || opts.nothrow) return out

+ 3 - 0
packages/opencode/src/util/record.ts

@@ -0,0 +1,3 @@
+export function isRecord(value: unknown): value is Record<string, unknown> {
+  return !!value && typeof value === "object" && !Array.isArray(value)
+}

+ 8 - 4
packages/opencode/src/worktree/index.ts

@@ -9,6 +9,7 @@ import { ProjectTable } from "../project/project.sql"
 import type { ProjectID } from "../project/schema"
 import { Log } from "../util/log"
 import { Slug } from "@opencode-ai/util/slug"
+import { errorMessage } from "../util/error"
 import { BusEvent } from "@/bus/bus-event"
 import { GlobalBus } from "@/bus/global"
 import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
@@ -260,7 +261,7 @@ export namespace Worktree {
           })
             .then(() => true)
             .catch((error) => {
-              const message = error instanceof Error ? error.message : String(error)
+              const message = errorMessage(error)
               log.error("worktree bootstrap failed", { directory: info.directory, message })
               GlobalBus.emit("event", {
                 directory: info.directory,
@@ -344,9 +345,12 @@ export namespace Worktree {
 
       function cleanDirectory(target: string) {
         return Effect.promise(() =>
-          import("fs/promises").then((fsp) =>
-            fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
-          ),
+          import("fs/promises")
+            .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
+            .catch((error) => {
+              const message = errorMessage(error)
+              throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
+            }),
         )
       }
 

+ 90 - 0
packages/opencode/test/cli/tui/keybind-plugin.test.ts

@@ -0,0 +1,90 @@
+import { describe, expect, test } from "bun:test"
+import type { ParsedKey } from "@opentui/core"
+import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds"
+
+describe("createPluginKeybind", () => {
+  const defaults = {
+    open: "ctrl+o",
+    close: "escape",
+  }
+
+  test("uses defaults when overrides are missing", () => {
+    const api = {
+      match: () => false,
+      print: (key: string) => key,
+    }
+    const bind = createPluginKeybind(api, defaults)
+
+    expect(bind.all).toEqual(defaults)
+    expect(bind.get("open")).toBe("ctrl+o")
+    expect(bind.get("close")).toBe("escape")
+  })
+
+  test("applies valid overrides", () => {
+    const api = {
+      match: () => false,
+      print: (key: string) => key,
+    }
+    const bind = createPluginKeybind(api, defaults, {
+      open: "ctrl+alt+o",
+      close: "q",
+    })
+
+    expect(bind.all).toEqual({
+      open: "ctrl+alt+o",
+      close: "q",
+    })
+  })
+
+  test("ignores invalid overrides", () => {
+    const api = {
+      match: () => false,
+      print: (key: string) => key,
+    }
+    const bind = createPluginKeybind(api, defaults, {
+      open: "   ",
+      close: 1,
+      extra: "ctrl+x",
+    })
+
+    expect(bind.all).toEqual(defaults)
+    expect(bind.get("extra")).toBe("extra")
+  })
+
+  test("resolves names for match", () => {
+    const list: string[] = []
+    const api = {
+      match: (key: string) => {
+        list.push(key)
+        return true
+      },
+      print: (key: string) => key,
+    }
+    const bind = createPluginKeybind(api, defaults, {
+      open: "ctrl+shift+o",
+    })
+
+    bind.match("open", { name: "x" } as ParsedKey)
+    bind.match("ctrl+k", { name: "x" } as ParsedKey)
+
+    expect(list).toEqual(["ctrl+shift+o", "ctrl+k"])
+  })
+
+  test("resolves names for print", () => {
+    const list: string[] = []
+    const api = {
+      match: () => false,
+      print: (key: string) => {
+        list.push(key)
+        return `print:${key}`
+      },
+    }
+    const bind = createPluginKeybind(api, defaults, {
+      close: "q",
+    })
+
+    expect(bind.print("close")).toBe("print:q")
+    expect(bind.print("ctrl+p")).toBe("print:ctrl+p")
+    expect(list).toEqual(["q", "ctrl+p"])
+  })
+})

+ 61 - 0
packages/opencode/test/cli/tui/plugin-add.test.ts

@@ -0,0 +1,61 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { TuiConfig } from "../../../src/config/tui"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("adds tui plugin at runtime from spec", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const file = path.join(dir, "add-plugin.ts")
+      const spec = pathToFileURL(file).href
+      const marker = path.join(dir, "add.txt")
+
+      await Bun.write(
+        file,
+        `export default {
+  id: "demo.add",
+  tui: async () => {
+    await Bun.write(${JSON.stringify(marker)}, "called")
+  },
+}
+`,
+      )
+
+      return { spec, marker }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin: [],
+    plugin_meta: undefined,
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+
+    await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
+    expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add")).toEqual({
+      id: "demo.add",
+      source: "file",
+      spec: tmp.extra.spec,
+      target: tmp.extra.spec,
+      enabled: true,
+      active: true,
+    })
+  } finally {
+    await TuiPluginRuntime.dispose()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})

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

@@ -0,0 +1,95 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { TuiConfig } from "../../../src/config/tui"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("installs plugin without loading it", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const file = path.join(dir, "install-plugin.ts")
+      const spec = pathToFileURL(file).href
+      const marker = path.join(dir, "install.txt")
+
+      await Bun.write(
+        path.join(dir, "package.json"),
+        JSON.stringify(
+          {
+            name: "demo-install-plugin",
+            type: "module",
+            main: "./install-plugin.ts",
+            "oc-plugin": [["tui", { marker }]],
+          },
+          null,
+          2,
+        ),
+      )
+
+      await Bun.write(
+        file,
+        `export default {
+  id: "demo.install",
+  tui: async (_api, options) => {
+    if (!options?.marker) return
+    await Bun.write(options.marker, "loaded")
+  },
+}
+`,
+      )
+
+      return { spec, marker }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  let cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
+    plugin: [],
+    plugin_meta: 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({
+    state: {
+      path: {
+        state: path.join(tmp.path, "state.json"),
+        config: path.join(tmp.path, "tui.json"),
+        worktree: tmp.path,
+        directory: tmp.path,
+      },
+    },
+  })
+
+  try {
+    await TuiPluginRuntime.init(api)
+    cfg = {
+      plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
+      plugin_meta: {
+        [tmp.extra.spec]: {
+          scope: "local",
+          source: path.join(tmp.path, "tui.json"),
+        },
+      },
+    }
+
+    const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
+    expect(out).toMatchObject({
+      ok: true,
+      tui: true,
+    })
+
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+    await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("loaded")
+  } finally {
+    await TuiPluginRuntime.dispose()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})

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

@@ -0,0 +1,225 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { mockTuiRuntime } from "../../fixture/tui-runtime"
+import { TuiConfig } from "../../../src/config/tui"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("runs onDispose callbacks with aborted signal and is idempotent", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const file = path.join(dir, "plugin.ts")
+      const spec = pathToFileURL(file).href
+      const marker = path.join(dir, "marker.txt")
+
+      await Bun.write(
+        file,
+        `export default {
+  id: "demo.lifecycle",
+  tui: async (api, options) => {
+    api.event.on("event.test", () => {})
+    api.route.register([{ name: "lifecycle.route", render: () => null }])
+    api.lifecycle.onDispose(async () => {
+      const prev = await Bun.file(options.marker).text().catch(() => "")
+      await Bun.write(options.marker, prev + "custom\\n")
+    })
+    api.lifecycle.onDispose(async () => {
+      const prev = await Bun.file(options.marker).text().catch(() => "")
+      await Bun.write(options.marker, prev + "aborted:" + String(api.lifecycle.signal.aborted) + "\\n")
+    })
+  },
+}
+`,
+      )
+
+      return { spec, marker }
+    },
+  })
+
+  const restore = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    await TuiPluginRuntime.dispose()
+
+    const marker = await fs.readFile(tmp.extra.marker, "utf8")
+    expect(marker).toContain("custom")
+    expect(marker).toContain("aborted:true")
+
+    // second dispose is a no-op
+    await TuiPluginRuntime.dispose()
+    const after = await fs.readFile(tmp.extra.marker, "utf8")
+    expect(after).toBe(marker)
+  } finally {
+    await TuiPluginRuntime.dispose()
+    restore()
+  }
+})
+
+test("rolls back failed plugin and continues loading next", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const bad = path.join(dir, "bad-plugin.ts")
+      const good = path.join(dir, "good-plugin.ts")
+      const badSpec = pathToFileURL(bad).href
+      const goodSpec = pathToFileURL(good).href
+      const badMarker = path.join(dir, "bad-cleanup.txt")
+      const goodMarker = path.join(dir, "good-called.txt")
+
+      await Bun.write(
+        bad,
+        `export default {
+  id: "demo.bad",
+  tui: async (api, options) => {
+    api.route.register([{ name: "bad.route", render: () => null }])
+    api.lifecycle.onDispose(async () => {
+      await Bun.write(options.bad_marker, "cleaned")
+    })
+    throw new Error("bad plugin")
+  },
+}
+`,
+      )
+
+      await Bun.write(
+        good,
+        `export default {
+  id: "demo.good",
+  tui: async (_api, options) => {
+    await Bun.write(options.good_marker, "called")
+  },
+}
+`,
+      )
+
+      return { badSpec, goodSpec, badMarker, goodMarker }
+    },
+  })
+
+  const 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())
+    // bad plugin's onDispose ran during rollback
+    await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned")
+    // good plugin still loaded
+    await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
+  } finally {
+    await TuiPluginRuntime.dispose()
+    restore()
+  }
+})
+
+test("assigns sequential slot ids scoped to plugin", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const file = path.join(dir, "slot-plugin.ts")
+      const spec = pathToFileURL(file).href
+      const marker = path.join(dir, "slot-setup.txt")
+
+      await Bun.write(
+        file,
+        `import fs from "fs"
+
+const mark = (label) => {
+  fs.appendFileSync(${JSON.stringify(marker)}, label + "\\n")
+}
+
+export default {
+  id: "demo.slot",
+  tui: async (api) => {
+    const one = api.slots.register({
+      id: 1,
+      setup: () => { mark("one") },
+      slots: { home_logo() { return null } },
+    })
+    const two = api.slots.register({
+      id: 2,
+      setup: () => { mark("two") },
+      slots: { home_bottom() { return null } },
+    })
+    mark("id:" + one)
+    mark("id:" + two)
+  },
+}
+`,
+      )
+
+      return { spec, marker }
+    },
+  })
+
+  const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
+  const err = spyOn(console, "error").mockImplementation(() => {})
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+
+    const marker = await fs.readFile(tmp.extra.marker, "utf8")
+    expect(marker).toContain("one")
+    expect(marker).toContain("two")
+    expect(marker).toContain("id:demo.slot")
+    expect(marker).toContain("id:demo.slot:1")
+
+    // no initialization failures
+    const hit = err.mock.calls.find(
+      (item) => typeof item[0] === "string" && item[0].includes("failed to initialize tui plugin"),
+    )
+    expect(hit).toBeUndefined()
+  } finally {
+    await TuiPluginRuntime.dispose()
+    err.mockRestore()
+    restore()
+  }
+})
+
+test(
+  "times out hanging plugin cleanup on dispose",
+  async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = path.join(dir, "timeout-plugin.ts")
+        const spec = pathToFileURL(file).href
+
+        await Bun.write(
+          file,
+          `export default {
+  id: "demo.timeout",
+  tui: async (api) => {
+    api.lifecycle.onDispose(() => new Promise(() => {}))
+  },
+}
+`,
+        )
+
+        return { spec }
+      },
+    })
+
+    const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
+
+    try {
+      await TuiPluginRuntime.init(createTuiPluginApi())
+
+      const done = await new Promise<string>((resolve) => {
+        const timer = setTimeout(() => resolve("timeout"), 7000)
+        TuiPluginRuntime.dispose().then(() => {
+          clearTimeout(timer)
+          resolve("done")
+        })
+      })
+      expect(done).toBe("done")
+    } finally {
+      await TuiPluginRuntime.dispose()
+      restore()
+    }
+  },
+  { timeout: 15000 },
+)

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

@@ -0,0 +1,132 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { TuiConfig } from "../../../src/config/tui"
+import { BunProc } from "../../../src/bun"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("loads npm tui plugin from package ./tui export", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const mod = path.join(dir, "mods", "acme-plugin")
+      const marker = path.join(dir, "tui-called.txt")
+      await fs.mkdir(mod, { recursive: true })
+
+      await Bun.write(
+        path.join(mod, "package.json"),
+        JSON.stringify({
+          name: "acme-plugin",
+          type: "module",
+          exports: { ".": "./index.js", "./server": "./server.js", "./tui": "./tui.js" },
+        }),
+      )
+      await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
+      await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
+      await Bun.write(path.join(mod, "server.js"), "export default {}\n")
+      await Bun.write(
+        path.join(mod, "tui.js"),
+        `export default {
+  id: "demo.tui.export",
+  tui: async (_api, options) => {
+    if (!options?.marker) return
+    await Bun.write(${JSON.stringify(marker)}, "called")
+  },
+}
+`,
+      )
+
+      return { mod, marker, spec: "acme-plugin@1.0.0" }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
+    plugin_meta: {
+      [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
+    },
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+  const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
+    const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
+    expect(hit?.enabled).toBe(true)
+    expect(hit?.active).toBe(true)
+    expect(hit?.source).toBe("npm")
+  } finally {
+    await TuiPluginRuntime.dispose()
+    install.mockRestore()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})
+
+test("rejects npm tui export that resolves outside plugin directory", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const mod = path.join(dir, "mods", "acme-plugin")
+      const outside = path.join(dir, "outside")
+      const marker = path.join(dir, "outside-called.txt")
+      await fs.mkdir(mod, { recursive: true })
+      await fs.mkdir(outside, { recursive: true })
+
+      await Bun.write(
+        path.join(mod, "package.json"),
+        JSON.stringify({
+          name: "acme-plugin",
+          type: "module",
+          exports: { ".": "./index.js", "./tui": "./escape/tui.js" },
+        }),
+      )
+      await Bun.write(path.join(mod, "index.js"), "export default {}\n")
+      await Bun.write(
+        path.join(outside, "tui.js"),
+        `export default {
+  id: "demo.outside",
+  tui: async () => {
+    await Bun.write(${JSON.stringify(marker)}, "outside")
+  },
+}
+`,
+      )
+      await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
+
+      return { mod, marker, spec: "acme-plugin@1.0.0" }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin: [tmp.extra.spec],
+    plugin_meta: {
+      [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
+    },
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+  const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    // plugin code never ran
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+    // plugin not listed
+    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
+  }
+})

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

@@ -0,0 +1,71 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { TuiConfig } from "../../../src/config/tui"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("skips external tui plugins in pure mode", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const file = path.join(dir, "plugin.ts")
+      const spec = pathToFileURL(file).href
+      const marker = path.join(dir, "called.txt")
+      const meta = path.join(dir, "plugin-meta.json")
+
+      await Bun.write(
+        file,
+        `export default {
+  id: "demo.pure",
+  tui: async (_api, options) => {
+    if (!options?.marker) return
+    await Bun.write(options.marker, "called")
+  },
+}
+`,
+      )
+
+      return { spec, marker, meta }
+    },
+  })
+
+  const pure = process.env.OPENCODE_PURE
+  const meta = process.env.OPENCODE_PLUGIN_META_FILE
+  process.env.OPENCODE_PURE = "1"
+  process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
+
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
+    plugin_meta: {
+      [tmp.extra.spec]: {
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    },
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+  } finally {
+    await TuiPluginRuntime.dispose()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    if (pure === undefined) {
+      delete process.env.OPENCODE_PURE
+    } else {
+      process.env.OPENCODE_PURE = pure
+    }
+    if (meta === undefined) {
+      delete process.env.OPENCODE_PLUGIN_META_FILE
+    } else {
+      process.env.OPENCODE_PLUGIN_META_FILE = meta
+    }
+  }
+})

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

@@ -0,0 +1,563 @@
+import { beforeAll, describe, expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { Global } from "../../../src/global"
+import { TuiConfig } from "../../../src/config/tui"
+import { Config } from "../../../src/config/config"
+import { Filesystem } from "../../../src/util/filesystem"
+
+const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+type Row = Record<string, unknown>
+
+type Data = {
+  local: Row
+  global: Row
+  invalid: Row
+  preloaded: Row
+  fn_called: boolean
+  local_installed: string
+  global_installed: string
+  preloaded_installed: string
+  leaked_local_to_global: boolean
+  leaked_global_to_local: boolean
+  local_theme: string
+  global_theme: string
+}
+
+async function row(file: string): Promise<Row> {
+  return Filesystem.readJson<Row>(file)
+}
+
+async function load(): Promise<Data> {
+  const stamp = Date.now()
+  const globalConfigPath = path.join(Global.Path.config, "tui.json")
+  const backup = await Bun.file(globalConfigPath)
+    .text()
+    .catch(() => undefined)
+
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const localPluginPath = path.join(dir, "local-plugin.ts")
+      const invalidPluginPath = path.join(dir, "invalid-plugin.ts")
+      const preloadedPluginPath = path.join(dir, "preloaded-plugin.ts")
+      const globalPluginPath = path.join(dir, "global-plugin.ts")
+      const localSpec = pathToFileURL(localPluginPath).href
+      const invalidSpec = pathToFileURL(invalidPluginPath).href
+      const preloadedSpec = pathToFileURL(preloadedPluginPath).href
+      const globalSpec = pathToFileURL(globalPluginPath).href
+      const localThemeFile = `local-theme-${stamp}.json`
+      const invalidThemeFile = `invalid-theme-${stamp}.json`
+      const globalThemeFile = `global-theme-${stamp}.json`
+      const preloadedThemeFile = `preloaded-theme-${stamp}.json`
+      const localThemeName = localThemeFile.replace(/\.json$/, "")
+      const invalidThemeName = invalidThemeFile.replace(/\.json$/, "")
+      const globalThemeName = globalThemeFile.replace(/\.json$/, "")
+      const preloadedThemeName = preloadedThemeFile.replace(/\.json$/, "")
+      const localThemePath = path.join(dir, localThemeFile)
+      const invalidThemePath = path.join(dir, invalidThemeFile)
+      const globalThemePath = path.join(dir, globalThemeFile)
+      const preloadedThemePath = path.join(dir, preloadedThemeFile)
+      const localDest = path.join(dir, ".opencode", "themes", localThemeFile)
+      const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
+      const preloadedDest = path.join(dir, ".opencode", "themes", preloadedThemeFile)
+      const fnMarker = path.join(dir, "function-called.txt")
+      const localMarker = path.join(dir, "local-called.json")
+      const invalidMarker = path.join(dir, "invalid-called.json")
+      const globalMarker = path.join(dir, "global-called.json")
+      const preloadedMarker = path.join(dir, "preloaded-called.json")
+      const localConfigPath = path.join(dir, "tui.json")
+
+      await Bun.write(localThemePath, JSON.stringify({ theme: { primary: "#101010" } }, null, 2))
+      await Bun.write(invalidThemePath, "{ invalid json }")
+      await Bun.write(globalThemePath, JSON.stringify({ theme: { primary: "#202020" } }, null, 2))
+      await Bun.write(preloadedThemePath, JSON.stringify({ theme: { primary: "#f0f0f0" } }, null, 2))
+      await Bun.write(preloadedDest, JSON.stringify({ theme: { primary: "#303030" } }, null, 2))
+
+      await Bun.write(
+        localPluginPath,
+        `export const ignored = async (_input, options) => {
+  if (!options?.fn_marker) return
+  await Bun.write(options.fn_marker, "called")
+}
+
+export default {
+  id: "demo.local",
+  tui: async (api, options) => {
+    if (!options?.marker) return
+    const cfg_theme = api.tuiConfig.theme
+    const cfg_diff = api.tuiConfig.diff_style
+    const cfg_speed = api.tuiConfig.scroll_speed
+    const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
+    const cfg_submit = api.tuiConfig.keybinds?.input_submit
+    const key = api.keybind.create(
+      { modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
+      options.keybinds,
+    )
+    const kv_before = api.kv.get(options.kv_key, "missing")
+    api.kv.set(options.kv_key, "stored")
+    const kv_after = api.kv.get(options.kv_key, "missing")
+    const diff = api.state.session.diff(options.session_id)
+    const todo = api.state.session.todo(options.session_id)
+    const lsp = api.state.lsp()
+    const mcp = api.state.mcp()
+    const depth_before = api.ui.dialog.depth
+    const open_before = api.ui.dialog.open
+    const size_before = api.ui.dialog.size
+    api.ui.dialog.setSize("large")
+    const size_after = api.ui.dialog.size
+    api.ui.dialog.replace(() => null)
+    const depth_after = api.ui.dialog.depth
+    const open_after = api.ui.dialog.open
+    api.ui.dialog.clear()
+    const open_clear = api.ui.dialog.open
+    const before = api.theme.has(options.theme_name)
+    const set_missing = api.theme.set(options.theme_name)
+    await api.theme.install(options.theme_path)
+    const after = api.theme.has(options.theme_name)
+    const set_installed = api.theme.set(options.theme_name)
+    const first = await Bun.file(options.dest).text()
+    await Bun.write(options.source, JSON.stringify({ theme: { primary: "#fefefe" } }, null, 2))
+    await api.theme.install(options.theme_path)
+    const second = await Bun.file(options.dest).text()
+    await Bun.write(
+      options.marker,
+      JSON.stringify({
+        before,
+        set_missing,
+        after,
+        set_installed,
+        selected: api.theme.selected,
+        same: first === second,
+        key_modal: key.get("modal"),
+        key_close: key.get("close"),
+        key_unknown: key.get("ctrl+k"),
+        key_print: key.print("modal"),
+        kv_before,
+        kv_after,
+        kv_ready: api.kv.ready,
+        diff_count: diff.length,
+        diff_file: diff[0]?.file,
+        todo_count: todo.length,
+        todo_first: todo[0]?.content,
+        lsp_count: lsp.length,
+        mcp_count: mcp.length,
+        mcp_first: mcp[0]?.name,
+        depth_before,
+        open_before,
+        size_before,
+        size_after,
+        depth_after,
+        open_after,
+        open_clear,
+        cfg_theme,
+        cfg_diff,
+        cfg_speed,
+        cfg_accel,
+        cfg_submit,
+      }),
+    )
+  },
+}
+`,
+      )
+
+      await Bun.write(
+        invalidPluginPath,
+        `export default {
+  id: "demo.invalid",
+  tui: async (api, options) => {
+    if (!options?.marker) return
+    const before = api.theme.has(options.theme_name)
+    const set_missing = api.theme.set(options.theme_name)
+    await api.theme.install(options.theme_path)
+    const after = api.theme.has(options.theme_name)
+    const set_installed = api.theme.set(options.theme_name)
+    await Bun.write(
+      options.marker,
+      JSON.stringify({
+        before,
+        set_missing,
+        after,
+        set_installed,
+      }),
+    )
+  },
+}
+`,
+      )
+
+      await Bun.write(
+        preloadedPluginPath,
+        `export default {
+  id: "demo.preloaded",
+  tui: async (api, options) => {
+    if (!options?.marker) return
+    const before = api.theme.has(options.theme_name)
+    await api.theme.install(options.theme_path)
+    const after = api.theme.has(options.theme_name)
+    const text = await Bun.file(options.dest).text()
+    await Bun.write(
+      options.marker,
+      JSON.stringify({
+        before,
+        after,
+        text,
+      }),
+    )
+  },
+}
+`,
+      )
+
+      await Bun.write(
+        globalPluginPath,
+        `export default {
+  id: "demo.global",
+  tui: async (api, options) => {
+    if (!options?.marker) return
+    await api.theme.install(options.theme_path)
+    const has = api.theme.has(options.theme_name)
+    const set_installed = api.theme.set(options.theme_name)
+    await Bun.write(
+      options.marker,
+      JSON.stringify({
+        has,
+        set_installed,
+        selected: api.theme.selected,
+      }),
+    )
+  },
+}
+`,
+      )
+
+      await Bun.write(
+        globalConfigPath,
+        JSON.stringify(
+          {
+            plugin: [
+              [globalSpec, { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName }],
+            ],
+          },
+          null,
+          2,
+        ),
+      )
+
+      await Bun.write(
+        localConfigPath,
+        JSON.stringify(
+          {
+            plugin: [
+              [
+                localSpec,
+                {
+                  fn_marker: fnMarker,
+                  marker: localMarker,
+                  source: localThemePath,
+                  dest: localDest,
+                  theme_path: `./${localThemeFile}`,
+                  theme_name: localThemeName,
+                  kv_key: "plugin_state_key",
+                  session_id: "ses_test",
+                  keybinds: {
+                    modal: "ctrl+alt+m",
+                    close: "q",
+                  },
+                },
+              ],
+              [
+                invalidSpec,
+                {
+                  marker: invalidMarker,
+                  theme_path: `./${invalidThemeFile}`,
+                  theme_name: invalidThemeName,
+                },
+              ],
+              [
+                preloadedSpec,
+                {
+                  marker: preloadedMarker,
+                  dest: preloadedDest,
+                  theme_path: `./${preloadedThemeFile}`,
+                  theme_name: preloadedThemeName,
+                },
+              ],
+            ],
+          },
+          null,
+          2,
+        ),
+      )
+
+      return {
+        localThemeFile,
+        invalidThemeFile,
+        globalThemeFile,
+        preloadedThemeFile,
+        localThemeName,
+        invalidThemeName,
+        globalThemeName,
+        preloadedThemeName,
+        localDest,
+        globalDest,
+        preloadedDest,
+        localPluginPath,
+        invalidPluginPath,
+        globalPluginPath,
+        preloadedPluginPath,
+        localSpec,
+        invalidSpec,
+        globalSpec,
+        preloadedSpec,
+        fnMarker,
+        localMarker,
+        invalidMarker,
+        globalMarker,
+        preloadedMarker,
+      }
+    },
+  })
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const install = spyOn(Config, "installDependencies").mockResolvedValue()
+
+  try {
+    expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
+
+    await TuiPluginRuntime.init(
+      createTuiPluginApi({
+        tuiConfig: {
+          theme: "smoke",
+          diff_style: "stacked",
+          scroll_speed: 1.5,
+          scroll_acceleration: { enabled: true },
+          keybinds: {
+            input_submit: "ctrl+enter",
+          },
+        },
+        keybind: {
+          print: (key) => `print:${key}`,
+        },
+        state: {
+          session: {
+            diff(sessionID) {
+              if (sessionID !== "ses_test") return []
+              return [{ file: "src/app.ts", additions: 3, deletions: 1 }]
+            },
+            todo(sessionID) {
+              if (sessionID !== "ses_test") return []
+              return [{ content: "ship it", status: "pending" }]
+            },
+          },
+          lsp() {
+            return [{ id: "ts", root: "/tmp/project", status: "connected" }]
+          },
+          mcp() {
+            return [{ name: "github", status: "connected" }]
+          },
+        },
+        theme: {
+          has(name) {
+            return allThemes()[name] !== undefined
+          },
+        },
+      }),
+    )
+    const local = await row(tmp.extra.localMarker)
+    const global = await row(tmp.extra.globalMarker)
+    const invalid = await row(tmp.extra.invalidMarker)
+    const preloaded = await row(tmp.extra.preloadedMarker)
+    const fn_called = await fs
+      .readFile(tmp.extra.fnMarker, "utf8")
+      .then(() => true)
+      .catch(() => false)
+    const local_installed = await fs.readFile(tmp.extra.localDest, "utf8")
+    const global_installed = await fs.readFile(tmp.extra.globalDest, "utf8")
+    const preloaded_installed = await fs.readFile(tmp.extra.preloadedDest, "utf8")
+    const leaked_local_to_global = await fs
+      .stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
+      .then(() => true)
+      .catch(() => false)
+    const leaked_global_to_local = await fs
+      .stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
+      .then(() => true)
+      .catch(() => false)
+
+    return {
+      local,
+      global,
+      invalid,
+      preloaded,
+      fn_called,
+      local_installed,
+      global_installed,
+      preloaded_installed,
+      leaked_local_to_global,
+      leaked_global_to_local,
+      local_theme: tmp.extra.localThemeName,
+      global_theme: tmp.extra.globalThemeName,
+    }
+  } finally {
+    await TuiPluginRuntime.dispose()
+    cwd.mockRestore()
+    wait.mockRestore()
+    install.mockRestore()
+    if (backup === undefined) {
+      await fs.rm(globalConfigPath, { force: true })
+    } else {
+      await Bun.write(globalConfigPath, backup)
+    }
+    await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
+  }
+}
+
+test("continues loading when a plugin is missing config metadata", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const bad = path.join(dir, "missing-meta-plugin.ts")
+      const good = path.join(dir, "next-plugin.ts")
+      const bare = path.join(dir, "plain-plugin.ts")
+      const badSpec = pathToFileURL(bad).href
+      const goodSpec = pathToFileURL(good).href
+      const bareSpec = pathToFileURL(bare).href
+      const goodMarker = path.join(dir, "next-called.txt")
+      const bareMarker = path.join(dir, "plain-called.txt")
+
+      for (const [file, id] of [
+        [bad, "demo.missing-meta"],
+        [good, "demo.next"],
+      ] as const) {
+        await Bun.write(
+          file,
+          `export default {
+  id: "${id}",
+  tui: async (_api, options) => {
+    if (!options?.marker) return
+    await Bun.write(options.marker, "called")
+  },
+}
+`,
+        )
+      }
+
+      await Bun.write(
+        bare,
+        `export default {
+  id: "demo.plain",
+  tui: async (_api, options) => {
+    await Bun.write(${JSON.stringify(bareMarker)}, options === undefined ? "undefined" : "value")
+  },
+}
+`,
+      )
+
+      return { badSpec, goodSpec, bareSpec, goodMarker, bareMarker }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin: [
+      [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
+      [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
+      tmp.extra.bareSpec,
+    ],
+    plugin_meta: {
+      [tmp.extra.goodSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
+      [tmp.extra.bareSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
+    },
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    // bad plugin was skipped (no metadata entry)
+    await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
+    // good plugin loaded fine
+    await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
+    // bare string spec gets undefined options
+    await expect(fs.readFile(tmp.extra.bareMarker, "utf8")).resolves.toBe("undefined")
+  } finally {
+    await TuiPluginRuntime.dispose()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})
+
+describe("tui.plugin.loader", () => {
+  let data: Data
+
+  beforeAll(async () => {
+    data = await load()
+  })
+
+  test("passes keybind, kv, state, and dialog APIs to v1 plugins", () => {
+    expect(data.local.key_modal).toBe("ctrl+alt+m")
+    expect(data.local.key_close).toBe("q")
+    expect(data.local.key_unknown).toBe("ctrl+k")
+    expect(data.local.key_print).toBe("print:ctrl+alt+m")
+    expect(data.local.kv_before).toBe("missing")
+    expect(data.local.kv_after).toBe("stored")
+    expect(data.local.kv_ready).toBe(true)
+    expect(data.local.diff_count).toBe(1)
+    expect(data.local.diff_file).toBe("src/app.ts")
+    expect(data.local.todo_count).toBe(1)
+    expect(data.local.todo_first).toBe("ship it")
+    expect(data.local.lsp_count).toBe(1)
+    expect(data.local.mcp_count).toBe(1)
+    expect(data.local.mcp_first).toBe("github")
+    expect(data.local.depth_before).toBe(0)
+    expect(data.local.open_before).toBe(false)
+    expect(data.local.size_before).toBe("medium")
+    expect(data.local.size_after).toBe("large")
+    expect(data.local.depth_after).toBe(1)
+    expect(data.local.open_after).toBe(true)
+    expect(data.local.open_clear).toBe(false)
+    expect(data.local.cfg_theme).toBe("smoke")
+    expect(data.local.cfg_diff).toBe("stacked")
+    expect(data.local.cfg_speed).toBe(1.5)
+    expect(data.local.cfg_accel).toBe(true)
+    expect(data.local.cfg_submit).toBe("ctrl+enter")
+  })
+
+  test("installs themes in the correct scope and remains resilient", () => {
+    expect(data.local.before).toBe(false)
+    expect(data.local.set_missing).toBe(false)
+    expect(data.local.after).toBe(true)
+    expect(data.local.set_installed).toBe(true)
+    expect(data.local.selected).toBe(data.local_theme)
+    expect(data.local.same).toBe(true)
+
+    expect(data.global.has).toBe(true)
+    expect(data.global.set_installed).toBe(true)
+    expect(data.global.selected).toBe(data.global_theme)
+
+    expect(data.invalid.before).toBe(false)
+    expect(data.invalid.set_missing).toBe(false)
+    expect(data.invalid.after).toBe(false)
+    expect(data.invalid.set_installed).toBe(false)
+
+    expect(data.preloaded.before).toBe(true)
+    expect(data.preloaded.after).toBe(true)
+    expect(data.preloaded.text).toContain("#303030")
+    expect(data.preloaded.text).not.toContain("#f0f0f0")
+
+    expect(data.fn_called).toBe(false)
+    expect(data.local_installed).toContain("#101010")
+    expect(data.local_installed).not.toContain("#fefefe")
+    expect(data.global_installed).toContain("#202020")
+    expect(data.preloaded_installed).toContain("#303030")
+    expect(data.preloaded_installed).not.toContain("#f0f0f0")
+    expect(data.leaked_local_to_global).toBe(false)
+    expect(data.leaked_global_to_local).toBe(false)
+  })
+})

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

@@ -0,0 +1,157 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { TuiConfig } from "../../../src/config/tui"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("toggles plugin runtime state by exported id", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const file = path.join(dir, "toggle-plugin.ts")
+      const spec = pathToFileURL(file).href
+      const marker = path.join(dir, "toggle.txt")
+
+      await Bun.write(
+        file,
+        `export default {
+  id: "demo.toggle",
+  tui: async (api, options) => {
+    const text = await Bun.file(options.marker).text().catch(() => "")
+    await Bun.write(options.marker, text + "start\\n")
+    api.lifecycle.onDispose(async () => {
+      const next = await Bun.file(options.marker).text().catch(() => "")
+      await Bun.write(options.marker, next + "stop\\n")
+    })
+  },
+}
+`,
+      )
+
+      return {
+        spec,
+        marker,
+      }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
+    plugin_enabled: {
+      "demo.toggle": false,
+    },
+    plugin_meta: {
+      [tmp.extra.spec]: {
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    },
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+  const api = createTuiPluginApi()
+
+  try {
+    await TuiPluginRuntime.init(api)
+
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+    expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({
+      id: "demo.toggle",
+      source: "file",
+      spec: tmp.extra.spec,
+      target: tmp.extra.spec,
+      enabled: false,
+      active: false,
+    })
+
+    await expect(TuiPluginRuntime.activatePlugin("demo.toggle")).resolves.toBe(true)
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\n")
+    expect(api.kv.get("plugin_enabled", {})).toEqual({
+      "demo.toggle": true,
+    })
+
+    await expect(TuiPluginRuntime.deactivatePlugin("demo.toggle")).resolves.toBe(true)
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\nstop\n")
+    expect(api.kv.get("plugin_enabled", {})).toEqual({
+      "demo.toggle": false,
+    })
+
+    await expect(TuiPluginRuntime.activatePlugin("missing.id")).resolves.toBe(false)
+  } finally {
+    await TuiPluginRuntime.dispose()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})
+
+test("kv plugin_enabled overrides tui config on startup", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const file = path.join(dir, "startup-plugin.ts")
+      const spec = pathToFileURL(file).href
+      const marker = path.join(dir, "startup.txt")
+
+      await Bun.write(
+        file,
+        `export default {
+  id: "demo.startup",
+  tui: async (_api, options) => {
+    await Bun.write(options.marker, "on")
+  },
+}
+`,
+      )
+
+      return {
+        spec,
+        marker,
+      }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
+    plugin_enabled: {
+      "demo.startup": false,
+    },
+    plugin_meta: {
+      [tmp.extra.spec]: {
+        scope: "local",
+        source: path.join(tmp.path, "tui.json"),
+      },
+    },
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+  const api = createTuiPluginApi()
+  api.kv.set("plugin_enabled", {
+    "demo.startup": true,
+  })
+
+  try {
+    await TuiPluginRuntime.init(api)
+
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on")
+    expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({
+      id: "demo.startup",
+      source: "file",
+      spec: tmp.extra.spec,
+      target: tmp.extra.spec,
+      enabled: true,
+      active: true,
+    })
+  } finally {
+    await TuiPluginRuntime.dispose()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})

+ 50 - 0
packages/opencode/test/cli/tui/theme-store.test.ts

@@ -0,0 +1,50 @@
+import { expect, test } from "bun:test"
+
+const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme } =
+  await import("../../../src/cli/cmd/tui/context/theme")
+
+test("addTheme writes into module theme store", () => {
+  const name = `plugin-theme-${Date.now()}`
+  expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
+
+  expect(allThemes()[name]).toBeDefined()
+})
+
+test("addTheme keeps first theme for duplicate names", () => {
+  const name = `plugin-theme-keep-${Date.now()}`
+  const one = structuredClone(DEFAULT_THEMES.opencode)
+  const two = structuredClone(DEFAULT_THEMES.opencode)
+  one.theme.primary = "#101010"
+  two.theme.primary = "#fefefe"
+
+  expect(addTheme(name, one)).toBe(true)
+  expect(addTheme(name, two)).toBe(false)
+
+  expect(allThemes()[name]).toBeDefined()
+  expect(allThemes()[name]!.theme.primary).toBe("#101010")
+})
+
+test("addTheme ignores entries without a theme object", () => {
+  const name = `plugin-theme-invalid-${Date.now()}`
+  expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
+  expect(allThemes()[name]).toBeUndefined()
+})
+
+test("hasTheme checks theme presence", () => {
+  const name = `plugin-theme-has-${Date.now()}`
+  expect(hasTheme(name)).toBe(false)
+  expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
+  expect(hasTheme(name)).toBe(true)
+})
+
+test("resolveTheme rejects circular color refs", () => {
+  const item = structuredClone(DEFAULT_THEMES.opencode)
+  item.defs = {
+    ...(item.defs ?? {}),
+    one: "two",
+    two: "one",
+  }
+  item.theme.primary = "one"
+
+  expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
+})

+ 165 - 48
packages/opencode/test/config/config.test.ts

@@ -20,6 +20,7 @@ import { pathToFileURL } from "url"
 import { Global } from "../../src/global"
 import { ProjectID } from "../../src/project/schema"
 import { Filesystem } from "../../src/util/filesystem"
+import * as Network from "../../src/util/network"
 import { BunProc } from "../../src/bun"
 
 const emptyAccount = Layer.mock(Account.Service)({
@@ -765,6 +766,20 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
 
   const prev = process.env.OPENCODE_CONFIG_DIR
   process.env.OPENCODE_CONFIG_DIR = tmp.extra
+  const online = spyOn(Network, "online").mockReturnValue(false)
+  const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
+    const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
+    await fs.mkdir(mod, { recursive: true })
+    await Filesystem.write(
+      path.join(mod, "package.json"),
+      JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
+    )
+    return {
+      code: 0,
+      stdout: Buffer.alloc(0),
+      stderr: Buffer.alloc(0),
+    }
+  })
 
   try {
     await Instance.provide({
@@ -778,25 +793,43 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
     expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
     expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
   } finally {
+    online.mockRestore()
+    run.mockRestore()
     if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
     else process.env.OPENCODE_CONFIG_DIR = prev
   }
 })
 
-test("serializes concurrent config dependency installs", async () => {
+test("dedupes concurrent config dependency installs for the same dir", async () => {
   await using tmp = await tmpdir()
-  const dirs = [path.join(tmp.path, "a"), path.join(tmp.path, "b")]
-  await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true })))
+  const dir = path.join(tmp.path, "a")
+  await fs.mkdir(dir, { recursive: true })
 
-  const seen: string[] = []
-  let active = 0
-  let max = 0
+  const ticks: number[] = []
+  let calls = 0
+  let start = () => {}
+  let done = () => {}
+  let blocked = () => {}
+  const ready = new Promise<void>((resolve) => {
+    start = resolve
+  })
+  const gate = new Promise<void>((resolve) => {
+    done = resolve
+  })
+  const waiting = new Promise<void>((resolve) => {
+    blocked = resolve
+  })
+  const online = spyOn(Network, "online").mockReturnValue(false)
   const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
-    active++
-    max = Math.max(max, active)
-    seen.push(opts?.cwd ?? "")
-    await new Promise((resolve) => setTimeout(resolve, 25))
-    active--
+    calls += 1
+    start()
+    await gate
+    const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
+    await fs.mkdir(mod, { recursive: true })
+    await Filesystem.write(
+      path.join(mod, "package.json"),
+      JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
+    )
     return {
       code: 0,
       stdout: Buffer.alloc(0),
@@ -805,15 +838,85 @@ test("serializes concurrent config dependency installs", async () => {
   })
 
   try {
-    await Promise.all(dirs.map((dir) => Config.installDependencies(dir)))
+    const first = Config.installDependencies(dir)
+    await ready
+    const second = Config.installDependencies(dir, {
+      waitTick: (tick) => {
+        ticks.push(tick.attempt)
+        blocked()
+        blocked = () => {}
+      },
+    })
+    await waiting
+    done()
+    await Promise.all([first, second])
   } finally {
+    online.mockRestore()
     run.mockRestore()
   }
 
-  expect(max).toBe(1)
-  expect(seen.toSorted()).toEqual(dirs.toSorted())
-  expect(await Filesystem.exists(path.join(dirs[0], "package.json"))).toBe(true)
-  expect(await Filesystem.exists(path.join(dirs[1], "package.json"))).toBe(true)
+  expect(calls).toBe(1)
+  expect(ticks.length).toBeGreaterThan(0)
+  expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
+})
+
+test("serializes config dependency installs across dirs", async () => {
+  if (process.platform !== "win32") return
+
+  await using tmp = await tmpdir()
+  const a = path.join(tmp.path, "a")
+  const b = path.join(tmp.path, "b")
+  await fs.mkdir(a, { recursive: true })
+  await fs.mkdir(b, { recursive: true })
+
+  let calls = 0
+  let open = 0
+  let peak = 0
+  let start = () => {}
+  let done = () => {}
+  const ready = new Promise<void>((resolve) => {
+    start = resolve
+  })
+  const gate = new Promise<void>((resolve) => {
+    done = resolve
+  })
+
+  const online = spyOn(Network, "online").mockReturnValue(false)
+  const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
+    calls += 1
+    open += 1
+    peak = Math.max(peak, open)
+    if (calls === 1) {
+      start()
+      await gate
+    }
+    const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
+    await fs.mkdir(mod, { recursive: true })
+    await Filesystem.write(
+      path.join(mod, "package.json"),
+      JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
+    )
+    open -= 1
+    return {
+      code: 0,
+      stdout: Buffer.alloc(0),
+      stderr: Buffer.alloc(0),
+    }
+  })
+
+  try {
+    const first = Config.installDependencies(a)
+    await ready
+    const second = Config.installDependencies(b)
+    done()
+    await Promise.all([first, second])
+  } finally {
+    online.mockRestore()
+    run.mockRestore()
+  }
+
+  expect(calls).toBe(2)
+  expect(peak).toBe(1)
 })
 
 test("resolves scoped npm plugins in config", async () => {
@@ -855,15 +958,7 @@ test("resolves scoped npm plugins in config", async () => {
     fn: async () => {
       const config = await Config.get()
       const pluginEntries = config.plugin ?? []
-
-      const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
-      const expected = pathToFileURL(path.join(tmp.path, "node_modules", "@scope", "plugin", "index.js")).href
-
-      expect(pluginEntries.includes(expected)).toBe(true)
-
-      const scopedEntry = pluginEntries.find((entry) => entry === expected)
-      expect(scopedEntry).toBeDefined()
-      expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
+      expect(pluginEntries).toContain("@scope/plugin")
     },
   })
 })
@@ -1710,27 +1805,43 @@ test("wellknown URL with trailing slash is normalized", async () => {
   }
 })
 
-describe("getPluginName", () => {
-  test("extracts name from file:// URL", () => {
-    expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
-    expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
-    expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
+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")
   })
 
-  test("extracts name from npm package with version", () => {
-    expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode")
-    expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin")
-    expect(Config.getPluginName("plugin@latest")).toBe("plugin")
-  })
+  test("resolves relative file plugin paths to file urls", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Filesystem.write(path.join(dir, "plugin.ts"), "export default {}")
+      },
+    })
 
-  test("extracts name from scoped npm package", () => {
-    expect(Config.getPluginName("@scope/pkg@1.0.0")).toBe("@scope/pkg")
-    expect(Config.getPluginName("@opencode/plugin@2.0.0")).toBe("@opencode/plugin")
+    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)
   })
 
-  test("returns full string for package without version", () => {
-    expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
-    expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
+  test("resolves plugin directory paths to package main files", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const plugin = path.join(dir, "plugin")
+        await fs.mkdir(plugin, { recursive: true })
+        await Filesystem.writeJson(path.join(plugin, "package.json"), {
+          name: "demo-plugin",
+          type: "module",
+          main: "./index.ts",
+        })
+        await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
+      },
+    })
+
+    const file = path.join(tmp.path, "opencode.json")
+    const hit = await Config.resolvePluginSpec("./plugin", file)
+    expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
   })
 })
 
@@ -1747,13 +1858,20 @@ describe("deduplicatePlugins", () => {
     expect(result.length).toBe(3)
   })
 
-  test("prefers local file over npm package with same name", () => {
+  test("keeps path plugins separate from package plugins", () => {
     const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
 
     const result = Config.deduplicatePlugins(plugins)
 
-    expect(result.length).toBe(1)
-    expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
+    expect(result).toEqual(plugins)
+  })
+
+  test("deduplicates direct path plugins by exact spec", () => {
+    const plugins = ["file:///project/.opencode/plugin/demo.ts", "file:///project/.opencode/plugin/demo.ts"]
+
+    const result = Config.deduplicatePlugins(plugins)
+
+    expect(result).toEqual(["file:///project/.opencode/plugin/demo.ts"])
   })
 
   test("preserves order of remaining plugins", () => {
@@ -1764,7 +1882,7 @@ describe("deduplicatePlugins", () => {
     expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
   })
 
-  test("local plugin directory overrides global opencode.json plugin", async () => {
+  test("loads auto-discovered local plugins as file urls", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
         const projectDir = path.join(dir, "project")
@@ -1790,9 +1908,8 @@ describe("deduplicatePlugins", () => {
         const config = await Config.get()
         const plugins = config.plugin ?? []
 
-        const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
-        expect(myPlugins.length).toBe(1)
-        expect(myPlugins[0].startsWith("file://")).toBe(true)
+        expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
+        expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true)
       },
     })
   })

+ 159 - 2
packages/opencode/test/config/tui.test.ts

@@ -458,9 +458,15 @@ test("applies file substitutions when first identical token is in a commented li
 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" }, null, 2))
+      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" }, null, 2))
+      await Bun.write(
+        path.join(managedConfigDir, "tui.json"),
+        JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2),
+      )
     },
   })
 
@@ -469,6 +475,13 @@ test("loads managed tui config and gives it highest precedence", async () => {
     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_meta).toEqual({
+        "shared-plugin@2.0.0": {
+          scope: "global",
+          source: path.join(managedConfigDir, "tui.json"),
+        },
+      })
     },
   })
 })
@@ -508,3 +521,147 @@ test("gracefully falls back when tui.json has invalid JSON", async () => {
     },
   })
 })
+
+test("supports tuple plugin specs with options in tui.json", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "tui.json"),
+        JSON.stringify({
+          plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]],
+        }),
+      )
+    },
+  })
+
+  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_meta).toEqual({
+        "acme-plugin@1.2.3": {
+          scope: "local",
+          source: path.join(tmp.path, "tui.json"),
+        },
+      })
+    },
+  })
+})
+
+test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(Global.Path.config, "tui.json"),
+        JSON.stringify({
+          plugin: [["acme-plugin@1.0.0", { source: "global" }]],
+        }),
+      )
+      await Bun.write(
+        path.join(dir, "tui.json"),
+        JSON.stringify({
+          plugin: [
+            ["acme-plugin@2.0.0", { source: "project" }],
+            ["second-plugin@3.0.0", { source: "project" }],
+          ],
+        }),
+      )
+    },
+  })
+
+  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_meta).toEqual({
+        "acme-plugin@2.0.0": {
+          scope: "local",
+          source: path.join(tmp.path, "tui.json"),
+        },
+        "second-plugin@3.0.0": {
+          scope: "local",
+          source: path.join(tmp.path, "tui.json"),
+        },
+      })
+    },
+  })
+})
+
+test("tracks global and local plugin metadata in merged tui config", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(Global.Path.config, "tui.json"),
+        JSON.stringify({
+          plugin: ["global-plugin@1.0.0"],
+        }),
+      )
+      await Bun.write(
+        path.join(dir, "tui.json"),
+        JSON.stringify({
+          plugin: ["local-plugin@2.0.0"],
+        }),
+      )
+    },
+  })
+
+  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_meta).toEqual({
+        "global-plugin@1.0.0": {
+          scope: "global",
+          source: path.join(Global.Path.config, "tui.json"),
+        },
+        "local-plugin@2.0.0": {
+          scope: "local",
+          source: path.join(tmp.path, "tui.json"),
+        },
+      })
+    },
+  })
+})
+
+test("merges plugin_enabled flags across config layers", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(Global.Path.config, "tui.json"),
+        JSON.stringify({
+          plugin_enabled: {
+            "internal:sidebar-context": false,
+            "demo.plugin": true,
+          },
+        }),
+      )
+      await Bun.write(
+        path.join(dir, "tui.json"),
+        JSON.stringify({
+          plugin_enabled: {
+            "demo.plugin": false,
+            "local.plugin": true,
+          },
+        }),
+      )
+    },
+  })
+
+  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,
+      })
+    },
+  })
+})

+ 72 - 0
packages/opencode/test/fixture/flock-worker.ts

@@ -0,0 +1,72 @@
+import fs from "fs/promises"
+import { Flock } from "../../src/util/flock"
+
+type Msg = {
+  key: string
+  dir: string
+  staleMs?: number
+  timeoutMs?: number
+  baseDelayMs?: number
+  maxDelayMs?: number
+  holdMs?: number
+  ready?: string
+  active?: string
+  done?: string
+}
+
+function sleep(ms: number) {
+  return new Promise<void>((resolve) => {
+    setTimeout(resolve, ms)
+  })
+}
+
+function input() {
+  const raw = process.argv[2]
+  if (!raw) {
+    throw new Error("Missing flock worker input")
+  }
+
+  return JSON.parse(raw) as Msg
+}
+
+async function job(input: Msg) {
+  if (input.ready) {
+    await fs.writeFile(input.ready, String(process.pid))
+  }
+
+  if (input.active) {
+    await fs.writeFile(input.active, String(process.pid), { flag: "wx" })
+  }
+
+  try {
+    if (input.holdMs && input.holdMs > 0) {
+      await sleep(input.holdMs)
+    }
+
+    if (input.done) {
+      await fs.appendFile(input.done, "1\n")
+    }
+  } finally {
+    if (input.active) {
+      await fs.rm(input.active, { force: true })
+    }
+  }
+}
+
+async function main() {
+  const msg = input()
+
+  await Flock.withLock(msg.key, () => job(msg), {
+    dir: msg.dir,
+    staleMs: msg.staleMs,
+    timeoutMs: msg.timeoutMs,
+    baseDelayMs: msg.baseDelayMs,
+    maxDelayMs: msg.maxDelayMs,
+  })
+}
+
+await main().catch((err) => {
+  const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
+  process.stderr.write(text)
+  process.exit(1)
+})

+ 93 - 0
packages/opencode/test/fixture/plug-worker.ts

@@ -0,0 +1,93 @@
+import path from "path"
+
+import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
+import { Filesystem } from "../../src/util/filesystem"
+
+type Msg = {
+  dir: string
+  target: string
+  mod: string
+  global?: boolean
+  force?: boolean
+  globalDir?: string
+  vcs?: string
+  worktree?: string
+  directory?: string
+  holdMs?: number
+}
+
+function sleep(ms: number) {
+  return new Promise<void>((resolve) => {
+    setTimeout(resolve, ms)
+  })
+}
+
+function input() {
+  const raw = process.argv[2]
+  if (!raw) {
+    throw new Error("Missing plug worker input")
+  }
+
+  const msg = JSON.parse(raw) as Partial<Msg>
+  if (!msg.dir || !msg.target || !msg.mod) {
+    throw new Error("Invalid plug worker input")
+  }
+
+  return msg as Msg
+}
+
+function deps(msg: Msg): PlugDeps {
+  return {
+    spinner: () => ({
+      start() {},
+      stop() {},
+    }),
+    log: {
+      error() {},
+      info() {},
+      success() {},
+    },
+    resolve: async () => msg.target,
+    readText: (file) => Filesystem.readText(file),
+    write: async (file, text) => {
+      if (msg.holdMs && msg.holdMs > 0) {
+        await sleep(msg.holdMs)
+      }
+      await Filesystem.write(file, text)
+    },
+    exists: (file) => Filesystem.exists(file),
+    files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)],
+    global: msg.globalDir ?? path.join(msg.dir, ".global"),
+  }
+}
+
+function ctx(msg: Msg): PlugCtx {
+  return {
+    vcs: msg.vcs ?? "git",
+    worktree: msg.worktree ?? msg.dir,
+    directory: msg.directory ?? msg.dir,
+  }
+}
+
+async function main() {
+  const msg = input()
+  const run = createPlugTask(
+    {
+      mod: msg.mod,
+      global: msg.global,
+      force: msg.force,
+    },
+    deps(msg),
+  )
+
+  const ok = await run(ctx(msg))
+  if (!ok) {
+    throw new Error("Plug task failed")
+  }
+}
+
+await main().catch((err) => {
+  const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
+  process.stderr.write(text)
+  process.exit(1)
+})

+ 26 - 0
packages/opencode/test/fixture/plugin-meta-worker.ts

@@ -0,0 +1,26 @@
+type Msg = {
+  file: string
+  spec: string
+  target: string
+  id: string
+}
+
+const raw = process.argv[2]
+if (!raw) throw new Error("Missing worker payload")
+
+const value = JSON.parse(raw)
+if (!value || typeof value !== "object") {
+  throw new Error("Invalid worker payload")
+}
+
+const msg = Object.fromEntries(Object.entries(value))
+if (typeof msg.file !== "string" || typeof msg.spec !== "string" || typeof msg.target !== "string") {
+  throw new Error("Invalid worker payload")
+}
+if (typeof msg.id !== "string") throw new Error("Invalid worker payload")
+
+process.env.OPENCODE_PLUGIN_META_FILE = msg.file
+
+const { PluginMeta } = await import("../../src/plugin/meta")
+
+await PluginMeta.touch(msg.spec, msg.target, msg.id)

+ 334 - 0
packages/opencode/test/fixture/tui-plugin.ts

@@ -0,0 +1,334 @@
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { RGBA, type CliRenderer } from "@opentui/core"
+import { createPluginKeybind } from "../../src/cli/cmd/tui/context/plugin-keybinds"
+import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots"
+
+type Count = {
+  event_add: number
+  event_drop: number
+  route_add: number
+  route_drop: number
+  command_add: number
+  command_drop: number
+}
+
+function themeCurrent(): HostPluginApi["theme"]["current"] {
+  const a = RGBA.fromInts(0, 120, 240)
+  const b = RGBA.fromInts(120, 120, 120)
+  const c = RGBA.fromInts(230, 230, 230)
+  const d = RGBA.fromInts(120, 30, 30)
+  const e = RGBA.fromInts(140, 100, 40)
+  const f = RGBA.fromInts(20, 140, 80)
+  const g = RGBA.fromInts(20, 80, 160)
+  const h = RGBA.fromInts(40, 40, 40)
+  const i = RGBA.fromInts(60, 60, 60)
+  const j = RGBA.fromInts(80, 80, 80)
+  return {
+    primary: a,
+    secondary: b,
+    accent: a,
+    error: d,
+    warning: e,
+    success: f,
+    info: g,
+    text: c,
+    textMuted: b,
+    selectedListItemText: h,
+    background: h,
+    backgroundPanel: h,
+    backgroundElement: i,
+    backgroundMenu: i,
+    border: j,
+    borderActive: c,
+    borderSubtle: i,
+    diffAdded: f,
+    diffRemoved: d,
+    diffContext: b,
+    diffHunkHeader: b,
+    diffHighlightAdded: f,
+    diffHighlightRemoved: d,
+    diffAddedBg: h,
+    diffRemovedBg: h,
+    diffContextBg: h,
+    diffLineNumber: b,
+    diffAddedLineNumberBg: h,
+    diffRemovedLineNumberBg: h,
+    markdownText: c,
+    markdownHeading: c,
+    markdownLink: a,
+    markdownLinkText: g,
+    markdownCode: f,
+    markdownBlockQuote: e,
+    markdownEmph: e,
+    markdownStrong: c,
+    markdownHorizontalRule: b,
+    markdownListItem: a,
+    markdownListEnumeration: g,
+    markdownImage: a,
+    markdownImageText: g,
+    markdownCodeBlock: c,
+    syntaxComment: b,
+    syntaxKeyword: a,
+    syntaxFunction: g,
+    syntaxVariable: c,
+    syntaxString: f,
+    syntaxNumber: e,
+    syntaxType: a,
+    syntaxOperator: a,
+    syntaxPunctuation: c,
+    thinkingOpacity: 0.6,
+  }
+}
+
+type Opts = {
+  client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
+  scopedClient?: HostPluginApi["scopedClient"]
+  workspace?: Partial<HostPluginApi["workspace"]>
+  renderer?: HostPluginApi["renderer"]
+  count?: Count
+  keybind?: Partial<HostPluginApi["keybind"]>
+  tuiConfig?: HostPluginApi["tuiConfig"]
+  app?: Partial<HostPluginApi["app"]>
+  state?: {
+    ready?: HostPluginApi["state"]["ready"]
+    config?: HostPluginApi["state"]["config"]
+    provider?: HostPluginApi["state"]["provider"]
+    path?: HostPluginApi["state"]["path"]
+    vcs?: HostPluginApi["state"]["vcs"]
+    workspace?: Partial<HostPluginApi["state"]["workspace"]>
+    session?: Partial<HostPluginApi["state"]["session"]>
+    part?: HostPluginApi["state"]["part"]
+    lsp?: HostPluginApi["state"]["lsp"]
+    mcp?: HostPluginApi["state"]["mcp"]
+  }
+  theme?: {
+    selected?: string
+    has?: HostPluginApi["theme"]["has"]
+    set?: HostPluginApi["theme"]["set"]
+    install?: HostPluginApi["theme"]["install"]
+    mode?: HostPluginApi["theme"]["mode"]
+    ready?: boolean
+    current?: HostPluginApi["theme"]["current"]
+  }
+}
+
+export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
+  const kv: Record<string, unknown> = {}
+  const count = opts.count
+  const ctrl = new AbortController()
+  const own = createOpencodeClient({
+    baseUrl: "http://localhost:4096",
+  })
+  const fallback = () => own
+  const read =
+    typeof opts.client === "function"
+      ? opts.client
+      : opts.client
+        ? () => opts.client as HostPluginApi["client"]
+        : fallback
+  const client = () => read()
+  const scopedClient = opts.scopedClient ?? ((_workspaceID?: string) => client())
+  const workspace: HostPluginApi["workspace"] = {
+    current: opts.workspace?.current ?? (() => undefined),
+    set: opts.workspace?.set ?? (() => {}),
+  }
+  let depth = 0
+  let size: "medium" | "large" | "xlarge" = "medium"
+  const has = opts.theme?.has ?? (() => false)
+  let selected = opts.theme?.selected ?? "opencode"
+  const key = {
+    match: opts.keybind?.match ?? (() => false),
+    print: opts.keybind?.print ?? ((name: string) => name),
+  }
+  const set =
+    opts.theme?.set ??
+    ((name: string) => {
+      if (!has(name)) return false
+      selected = name
+      return true
+    })
+  const renderer: CliRenderer = opts.renderer ?? {
+    ...Object.create(null),
+    once(this: CliRenderer) {
+      return this
+    },
+  }
+
+  function kvGet(name: string): unknown
+  function kvGet<Value>(name: string, fallback: Value): Value
+  function kvGet(name: string, fallback?: unknown) {
+    const value = kv[name]
+    if (value === undefined) return fallback
+    return value
+  }
+
+  return {
+    app: {
+      get version() {
+        return opts.app?.version ?? "0.0.0-test"
+      },
+    },
+    get client() {
+      return client()
+    },
+    scopedClient,
+    workspace,
+    event: {
+      on: () => {
+        if (count) count.event_add += 1
+        return () => {
+          if (!count) return
+          count.event_drop += 1
+        }
+      },
+    },
+    renderer,
+    slots: {
+      register: () => "fixture-slot",
+    },
+    plugins: {
+      list: () => [],
+      activate: async () => false,
+      deactivate: async () => false,
+      add: async () => false,
+      install: async () => ({
+        ok: false,
+        message: "not implemented in fixture",
+      }),
+    },
+    lifecycle: {
+      signal: ctrl.signal,
+      onDispose() {
+        return () => {}
+      },
+    },
+    command: {
+      register: () => {
+        if (count) count.command_add += 1
+        return () => {
+          if (!count) return
+          count.command_drop += 1
+        }
+      },
+      trigger: () => {},
+    },
+    route: {
+      register: () => {
+        if (count) count.route_add += 1
+        return () => {
+          if (!count) return
+          count.route_drop += 1
+        }
+      },
+      navigate: () => {},
+      get current() {
+        return { name: "home" }
+      },
+    },
+    ui: {
+      Dialog: () => null,
+      DialogAlert: () => null,
+      DialogConfirm: () => null,
+      DialogPrompt: () => null,
+      DialogSelect: () => null,
+      toast: () => {},
+      dialog: {
+        replace: () => {
+          depth = 1
+        },
+        clear: () => {
+          depth = 0
+          size = "medium"
+        },
+        setSize: (next) => {
+          size = next
+        },
+        get size() {
+          return size
+        },
+        get depth() {
+          return depth
+        },
+        get open() {
+          return depth > 0
+        },
+      },
+    },
+    keybind: {
+      ...key,
+      create:
+        opts.keybind?.create ??
+        ((defaults, over) => {
+          return createPluginKeybind(key, defaults, over)
+        }),
+    },
+    tuiConfig: opts.tuiConfig ?? {},
+    kv: {
+      get: kvGet,
+      set(name, value) {
+        kv[name] = value
+      },
+      get ready() {
+        return true
+      },
+    },
+    state: {
+      get ready() {
+        return opts.state?.ready ?? true
+      },
+      get config() {
+        return opts.state?.config ?? {}
+      },
+      get provider() {
+        return opts.state?.provider ?? []
+      },
+      get path() {
+        return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" }
+      },
+      get vcs() {
+        return opts.state?.vcs
+      },
+      workspace: {
+        list: opts.state?.workspace?.list ?? (() => []),
+        get: opts.state?.workspace?.get ?? (() => undefined),
+      },
+      session: {
+        count: opts.state?.session?.count ?? (() => 0),
+        diff: opts.state?.session?.diff ?? (() => []),
+        todo: opts.state?.session?.todo ?? (() => []),
+        messages: opts.state?.session?.messages ?? (() => []),
+        status: opts.state?.session?.status ?? (() => undefined),
+        permission: opts.state?.session?.permission ?? (() => []),
+        question: opts.state?.session?.question ?? (() => []),
+      },
+      part: opts.state?.part ?? (() => []),
+      lsp: opts.state?.lsp ?? (() => []),
+      mcp: opts.state?.mcp ?? (() => []),
+    },
+    theme: {
+      get current() {
+        return opts.theme?.current ?? themeCurrent()
+      },
+      get selected() {
+        return selected
+      },
+      has(name) {
+        return has(name)
+      },
+      set(name) {
+        return set(name)
+      },
+      async install(file) {
+        if (opts.theme?.install) return opts.theme.install(file)
+        throw new Error("base theme.install should not run")
+      },
+      mode() {
+        if (opts.theme?.mode) return opts.theme.mode()
+        return "dark"
+      },
+      get ready() {
+        return opts.theme?.ready ?? true
+      },
+    },
+  }
+}

+ 34 - 0
packages/opencode/test/fixture/tui-runtime.ts

@@ -0,0 +1,34 @@
+import { spyOn } from "bun:test"
+import path from "path"
+import { TuiConfig } from "../../src/config/tui"
+
+type PluginSpec = string | [string, Record<string, unknown>]
+
+export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
+  const meta = Object.fromEntries(
+    plugin.map((item) => {
+      const spec = Array.isArray(item) ? item[0] : item
+      return [
+        spec,
+        {
+          scope: "local" as const,
+          source: path.join(dir, "tui.json"),
+        },
+      ]
+    }),
+  )
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin,
+    plugin_meta: meta,
+  })
+  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
+  }
+}

+ 12 - 9
packages/opencode/test/plugin/auth-override.test.ts

@@ -16,15 +16,18 @@ describe("plugin.auth-override", () => {
         await Bun.write(
           path.join(pluginDir, "custom-copilot-auth.ts"),
           [
-            "export default async () => ({",
-            "  auth: {",
-            '    provider: "github-copilot",',
-            "    methods: [",
-            '      { type: "api", label: "Test Override Auth" },',
-            "    ],",
-            "    loader: async () => ({ access: 'test-token' }),",
-            "  },",
-            "})",
+            "export default {",
+            '  id: "demo.custom-copilot-auth",',
+            "  server: async () => ({",
+            "    auth: {",
+            '      provider: "github-copilot",',
+            "      methods: [",
+            '        { type: "api", label: "Test Override Auth" },',
+            "      ],",
+            "      loader: async () => ({ access: 'test-token' }),",
+            "    },",
+            "  }),",
+            "}",
             "",
           ].join("\n"),
         )

+ 134 - 0
packages/opencode/test/plugin/install-concurrency.test.ts

@@ -0,0 +1,134 @@
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+
+import { Process } from "../../src/util/process"
+import { Filesystem } from "../../src/util/filesystem"
+import { tmpdir } from "../fixture/fixture"
+
+const root = path.join(import.meta.dir, "../..")
+const worker = path.join(import.meta.dir, "../fixture/plug-worker.ts")
+
+type Msg = {
+  dir: string
+  target: string
+  mod: string
+  holdMs?: number
+}
+
+function run(msg: Msg) {
+  return Process.run([process.execPath, worker, JSON.stringify(msg)], {
+    cwd: root,
+    nothrow: true,
+  })
+}
+
+async function plugin(dir: string, kinds: Array<"server" | "tui">) {
+  const p = path.join(dir, "plugin")
+  await fs.mkdir(p, { recursive: true })
+  await Bun.write(
+    path.join(p, "package.json"),
+    JSON.stringify(
+      {
+        name: "acme",
+        version: "1.0.0",
+        "oc-plugin": kinds,
+      },
+      null,
+      2,
+    ),
+  )
+  return p
+}
+
+async function read(file: string) {
+  return Filesystem.readJson<{ plugin?: unknown[] }>(file)
+}
+
+function mods(prefix: string, n: number) {
+  return Array.from({ length: n }, (_, i) => `${prefix}-${i}@1.0.0`)
+}
+
+function expectPlugins(list: unknown[] | undefined, expectMods: string[]) {
+  expect(Array.isArray(list)).toBe(true)
+  const hit = (list ?? []).filter((item): item is string => typeof item === "string")
+  expect(hit.length).toBe(expectMods.length)
+  expect(new Set(hit)).toEqual(new Set(expectMods))
+}
+
+describe("plugin.install.concurrent", () => {
+  test("serializes concurrent server config updates across processes", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server"])
+    const all = mods("mod-server", 12)
+
+    const out = await Promise.all(
+      all.map((mod) =>
+        run({
+          dir: tmp.path,
+          target,
+          mod,
+          holdMs: 30,
+        }),
+      ),
+    )
+
+    expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
+    expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
+
+    const cfg = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
+    expectPlugins(cfg.plugin, all)
+  }, 25_000)
+
+  test("serializes concurrent server+tui config updates across processes", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server", "tui"])
+    const all = mods("mod-both", 10)
+
+    const out = await Promise.all(
+      all.map((mod) =>
+        run({
+          dir: tmp.path,
+          target,
+          mod,
+          holdMs: 30,
+        }),
+      ),
+    )
+
+    expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
+    expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
+
+    const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
+    const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
+    expectPlugins(server.plugin, all)
+    expectPlugins(tui.plugin, all)
+  }, 25_000)
+
+  test("preserves updates when existing config uses .json", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server"])
+    const cfg = path.join(tmp.path, ".opencode", "opencode.json")
+    await fs.mkdir(path.dirname(cfg), { recursive: true })
+    await Bun.write(cfg, JSON.stringify({ plugin: ["seed@1.0.0"] }, null, 2))
+
+    const next = mods("mod-json", 8)
+    const out = await Promise.all(
+      next.map((mod) =>
+        run({
+          dir: tmp.path,
+          target,
+          mod,
+          holdMs: 30,
+        }),
+      ),
+    )
+
+    expect(out.map((x) => x.code)).toEqual(Array.from({ length: next.length }, () => 0))
+    expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
+
+    const json = await read(cfg)
+    expectPlugins(json.plugin, ["seed@1.0.0", ...next])
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+  }, 25_000)
+})

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

@@ -0,0 +1,410 @@
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { Filesystem } from "../../src/util/filesystem"
+import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
+import { tmpdir } from "../fixture/fixture"
+
+function deps(global: string, target: string | Error): PlugDeps {
+  return {
+    spinner: () => ({
+      start() {},
+      stop() {},
+    }),
+    log: {
+      error() {},
+      info() {},
+      success() {},
+    },
+    resolve: async () => {
+      if (target instanceof Error) throw target
+      return target
+    },
+    readText: (file) => Filesystem.readText(file),
+    write: async (file, text) => {
+      await Filesystem.write(file, text)
+    },
+    exists: (file) => Filesystem.exists(file),
+    files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)],
+    global,
+  }
+}
+
+function ctx(dir: string): PlugCtx {
+  return {
+    vcs: "git",
+    worktree: dir,
+    directory: dir,
+  }
+}
+
+function ctxDir(dir: string, worktree: string): PlugCtx {
+  return {
+    vcs: "none",
+    worktree,
+    directory: dir,
+  }
+}
+
+function ctxRoot(dir: string): PlugCtx {
+  return {
+    vcs: "git",
+    worktree: "/",
+    directory: dir,
+  }
+}
+
+async function plugin(dir: string, kinds?: unknown) {
+  const p = path.join(dir, "plugin")
+  await fs.mkdir(p, { recursive: true })
+  await Bun.write(
+    path.join(p, "package.json"),
+    JSON.stringify(
+      {
+        name: "acme",
+        version: "1.0.0",
+        ...(kinds === undefined ? {} : { "oc-plugin": kinds }),
+      },
+      null,
+      2,
+    ),
+  )
+  return p
+}
+
+async function read(file: string) {
+  return Filesystem.readJson<{
+    plugin?: unknown[]
+  }>(file)
+}
+
+describe("plugin.install.task", () => {
+  test("writes both server and tui config entries", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server", "tui"])
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(true)
+
+    const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
+    const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
+    expect(server.plugin).toEqual(["acme@1.2.3"])
+    expect(tui.plugin).toEqual(["acme@1.2.3"])
+  })
+
+  test("writes default options from tuple manifest targets", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, [
+      ["server", { custom: true, other: false }],
+      ["tui", { compact: true }],
+    ])
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(true)
+
+    const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
+    const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
+    expect(server.plugin).toEqual([["acme@1.2.3", { custom: true, other: false }]])
+    expect(tui.plugin).toEqual([["acme@1.2.3", { compact: true }]])
+  })
+
+  test("supports resolver target pointing to a file", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server"])
+    const file = path.join(target, "index.js")
+    await Bun.write(file, "export {}")
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), file),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(true)
+    const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
+    expect(server.plugin).toEqual(["acme@1.2.3"])
+  })
+
+  test("does not change configured package version without force", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server"])
+    const cfg = path.join(tmp.path, ".opencode", "opencode.json")
+    await fs.mkdir(path.dirname(cfg), { recursive: true })
+    await Bun.write(cfg, JSON.stringify({ plugin: ["acme@1.0.0"] }, null, 2))
+
+    const run = createPlugTask(
+      {
+        mod: "acme@2.0.0",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(true)
+    const json = await read(cfg)
+    expect(json.plugin).toEqual(["acme@1.0.0"])
+  })
+
+  test("does not change scoped package version without force", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server"])
+    const cfg = path.join(tmp.path, ".opencode", "opencode.json")
+    await fs.mkdir(path.dirname(cfg), { recursive: true })
+    await Bun.write(cfg, JSON.stringify({ plugin: ["@scope/acme@1.0.0"] }, null, 2))
+
+    const run = createPlugTask(
+      {
+        mod: "@scope/acme@2.0.0",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(true)
+    const json = await read(cfg)
+    expect(json.plugin).toEqual(["@scope/acme@1.0.0"])
+  })
+
+  test("keeps file plugin entries and still adds npm plugin", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server"])
+    const cfg = path.join(tmp.path, ".opencode", "opencode.json")
+    await fs.mkdir(path.dirname(cfg), { recursive: true })
+    await Bun.write(cfg, JSON.stringify({ plugin: ["file:///tmp/acme.ts"] }, null, 2))
+
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(true)
+    const json = await read(cfg)
+    expect(json.plugin).toEqual(["file:///tmp/acme.ts", "acme@1.2.3"])
+  })
+
+  test("force replaces configured package version and keeps tuple options", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server"])
+    const cfg = path.join(tmp.path, ".opencode", "opencode.json")
+    await fs.mkdir(path.dirname(cfg), { recursive: true })
+    await Bun.write(
+      cfg,
+      JSON.stringify(
+        {
+          plugin: [["acme@1.0.0", { mode: "safe" }], "acme@1.1.0", "other@1.0.0"],
+        },
+        null,
+        2,
+      ),
+    )
+
+    const run = createPlugTask(
+      {
+        mod: "acme@2.0.0",
+        force: true,
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(true)
+    const json = await read(cfg)
+    expect(json.plugin).toEqual([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"])
+  })
+
+  test("writes to global scope when global flag is set", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server"])
+    const global = path.join(tmp.path, "global")
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+        global: true,
+      },
+      deps(global, target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(true)
+
+    expect(await Filesystem.exists(path.join(global, "opencode.jsonc"))).toBe(true)
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+  })
+
+  test("writes local scope under directory when vcs is not git", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server"])
+    const directory = path.join(tmp.path, "dir")
+    const worktree = path.join(tmp.path, "worktree")
+    await fs.mkdir(directory, { recursive: true })
+    await fs.mkdir(worktree, { recursive: true })
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctxDir(directory, worktree))
+    expect(ok).toBe(true)
+    expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true)
+    expect(await Filesystem.exists(path.join(worktree, ".opencode", "opencode.jsonc"))).toBe(false)
+  })
+
+  test("writes local scope under directory when worktree is root slash", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server"])
+    const directory = path.join(tmp.path, "dir")
+    await fs.mkdir(directory, { recursive: true })
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctxRoot(directory))
+    expect(ok).toBe(true)
+    expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true)
+  })
+
+  test("writes tui local scope under directory when worktree is root slash", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["tui"])
+    const directory = path.join(tmp.path, "dir")
+    await fs.mkdir(directory, { recursive: true })
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctxRoot(directory))
+    expect(ok).toBe(true)
+    expect(await Filesystem.exists(path.join(directory, ".opencode", "tui.jsonc"))).toBe(true)
+  })
+
+  test("writes only tui config for tui-only plugins", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["tui"])
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(true)
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true)
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+  })
+
+  test("force replaces version in both server and tui configs", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server", "tui"])
+    const server = path.join(tmp.path, ".opencode", "opencode.json")
+    const tui = path.join(tmp.path, ".opencode", "tui.json")
+    await fs.mkdir(path.dirname(server), { recursive: true })
+    await Bun.write(server, JSON.stringify({ plugin: ["acme@1.0.0", "other@1.0.0"] }, null, 2))
+    await Bun.write(tui, JSON.stringify({ plugin: [["acme@1.0.0", { mode: "safe" }], "other@1.0.0"] }, null, 2))
+
+    const run = createPlugTask(
+      {
+        mod: "acme@2.0.0",
+        force: true,
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(true)
+    const serverJson = await read(server)
+    const tuiJson = await read(tui)
+    expect(serverJson.plugin).toEqual(["acme@2.0.0", "other@1.0.0"])
+    expect(tuiJson.plugin).toEqual([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"])
+  })
+
+  test("returns false and keeps config unchanged for invalid JSONC", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path, ["server"])
+    const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc")
+    await fs.mkdir(path.dirname(cfg), { recursive: true })
+    const bad = '{"plugin": ["acme@1.0.0",}'
+    await Bun.write(cfg, bad)
+
+    const run = createPlugTask(
+      {
+        mod: "acme@2.0.0",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(false)
+    expect(await fs.readFile(cfg, "utf8")).toBe(bad)
+  })
+
+  test("returns false when manifest declares no supported targets", async () => {
+    await using tmp = await tmpdir()
+    const target = await plugin(tmp.path)
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(false)
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false)
+  })
+
+  test("returns false when manifest cannot be read", async () => {
+    await using tmp = await tmpdir()
+    const target = path.join(tmp.path, "plugin")
+    await fs.mkdir(target, { recursive: true })
+    const run = createPlugTask(
+      {
+        mod: "acme@1.2.3",
+      },
+      deps(path.join(tmp.path, "global"), target),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(false)
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+  })
+
+  test("returns false when install fails", async () => {
+    await using tmp = await tmpdir()
+    const run = createPlugTask(
+      {
+        mod: "acme@9.9.9",
+      },
+      deps(path.join(tmp.path, "global"), new Error("boom")),
+    )
+
+    const ok = await run(ctx(tmp.path))
+    expect(ok).toBe(false)
+    expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+  })
+})

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

@@ -0,0 +1,548 @@
+import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../fixture/fixture"
+import { Filesystem } from "../../src/util/filesystem"
+
+const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
+process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
+
+const { Plugin } = await import("../../src/plugin/index")
+const { Instance } = await import("../../src/project/instance")
+const { BunProc } = await import("../../src/bun")
+const { Bus } = await import("../../src/bus")
+const { Session } = await import("../../src/session")
+
+afterAll(() => {
+  if (disableDefault === undefined) {
+    delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
+    return
+  }
+  process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
+})
+
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
+async function load(dir: string) {
+  return Instance.provide({
+    directory: dir,
+    fn: async () => {
+      await Plugin.list()
+    },
+  })
+}
+
+async function errs(dir: string) {
+  return Instance.provide({
+    directory: dir,
+    fn: async () => {
+      const errors: string[] = []
+      const off = Bus.subscribe(Session.Event.Error, (evt) => {
+        const error = evt.properties.error
+        if (!error || typeof error !== "object") return
+        if (!("data" in error)) return
+        if (!error.data || typeof error.data !== "object") return
+        if (!("message" in error.data)) return
+        if (typeof error.data.message !== "string") return
+        errors.push(error.data.message)
+      })
+      await Plugin.list()
+      off()
+      return errors
+    },
+  })
+}
+
+describe("plugin.loader.shared", () => {
+  test("loads a file:// plugin function export", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = path.join(dir, "plugin.ts")
+        const mark = path.join(dir, "called.txt")
+        await Bun.write(
+          file,
+          [
+            "export default async () => {",
+            `  await Bun.write(${JSON.stringify(mark)}, \"called\")`,
+            "  return {}",
+            "}",
+            "",
+          ].join("\n"),
+        )
+
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+        )
+
+        return { mark }
+      },
+    })
+
+    await load(tmp.path)
+    expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
+  })
+
+  test("deduplicates same function exported as default and named", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = path.join(dir, "plugin.ts")
+        const mark = path.join(dir, "count.txt")
+        await Bun.write(mark, "")
+        await Bun.write(
+          file,
+          [
+            "const run = async () => {",
+            `  const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => \"\")`,
+            `  await Bun.write(${JSON.stringify(mark)}, text + \"1\")`,
+            "  return {}",
+            "}",
+            "export default run",
+            "export const named = run",
+            "",
+          ].join("\n"),
+        )
+
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+        )
+
+        return { mark }
+      },
+    })
+
+    await load(tmp.path)
+    expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1")
+  })
+
+  test("uses only default v1 server plugin when present", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = path.join(dir, "plugin.ts")
+        const mark = path.join(dir, "count.txt")
+        await Bun.write(
+          file,
+          [
+            "export default {",
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, "default")`,
+            "    return {}",
+            "  },",
+            "}",
+            "export const named = async () => {",
+            `  await Bun.write(${JSON.stringify(mark)}, "named")`,
+            "  return {}",
+            "}",
+            "",
+          ].join("\n"),
+        )
+
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+        )
+
+        return { mark }
+      },
+    })
+
+    await load(tmp.path)
+    expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
+  })
+
+  test("resolves npm plugin specs with explicit and default versions", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const acme = path.join(dir, "node_modules", "acme-plugin")
+        const scope = path.join(dir, "node_modules", "scope-plugin")
+        await fs.mkdir(acme, { recursive: true })
+        await fs.mkdir(scope, { recursive: true })
+        await Bun.write(
+          path.join(acme, "package.json"),
+          JSON.stringify({ name: "acme-plugin", type: "module", main: "./index.js" }, null, 2),
+        )
+        await Bun.write(path.join(acme, "index.js"), "export default { server: async () => ({}) }\n")
+        await Bun.write(
+          path.join(scope, "package.json"),
+          JSON.stringify({ name: "scope-plugin", type: "module", main: "./index.js" }, null, 2),
+        )
+        await Bun.write(path.join(scope, "index.js"), "export default { server: async () => ({}) }\n")
+
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({ plugin: ["acme-plugin", "scope-plugin@2.3.4"] }, null, 2),
+        )
+
+        return { acme, scope }
+      },
+    })
+
+    const install = spyOn(BunProc, "install").mockImplementation(async (pkg) => {
+      if (pkg === "acme-plugin") return tmp.extra.acme
+      return tmp.extra.scope
+    })
+
+    try {
+      await load(tmp.path)
+
+      expect(install.mock.calls).toContainEqual(["acme-plugin", "latest"])
+      expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4"])
+    } finally {
+      install.mockRestore()
+    }
+  })
+
+  test("loads npm server plugin from package ./server export", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const mod = path.join(dir, "mods", "acme-plugin")
+        const mark = path.join(dir, "server-called.txt")
+        await fs.mkdir(mod, { recursive: true })
+
+        await Bun.write(
+          path.join(mod, "package.json"),
+          JSON.stringify(
+            {
+              name: "acme-plugin",
+              type: "module",
+              exports: {
+                ".": "./index.js",
+                "./server": "./server.js",
+                "./tui": "./tui.js",
+              },
+            },
+            null,
+            2,
+          ),
+        )
+        await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
+        await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
+        await Bun.write(
+          path.join(mod, "server.js"),
+          [
+            "export default {",
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, "called")`,
+            "    return {}",
+            "  },",
+            "}",
+            "",
+          ].join("\n"),
+        )
+        await Bun.write(path.join(mod, "tui.js"), "export default {}\n")
+
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
+
+        return {
+          mod,
+          mark,
+        }
+      },
+    })
+
+    const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+    try {
+      await load(tmp.path)
+      expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
+    } finally {
+      install.mockRestore()
+    }
+  })
+
+  test("rejects npm server export that resolves outside plugin directory", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const mod = path.join(dir, "mods", "acme-plugin")
+        const outside = path.join(dir, "outside")
+        const mark = path.join(dir, "outside-server.txt")
+        await fs.mkdir(mod, { recursive: true })
+        await fs.mkdir(outside, { recursive: true })
+
+        await Bun.write(
+          path.join(mod, "package.json"),
+          JSON.stringify(
+            {
+              name: "acme-plugin",
+              type: "module",
+              exports: {
+                ".": "./index.js",
+                "./server": "./escape/server.js",
+              },
+            },
+            null,
+            2,
+          ),
+        )
+        await Bun.write(path.join(mod, "index.js"), "export default {}\n")
+        await Bun.write(
+          path.join(outside, "server.js"),
+          [
+            "export default {",
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, "outside")`,
+            "    return {}",
+            "  },",
+            "}",
+            "",
+          ].join("\n"),
+        )
+        await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
+
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2))
+
+        return {
+          mod,
+          mark,
+        }
+      },
+    })
+
+    const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+    try {
+      const errors = await errs(tmp.path)
+      const called = await Bun.file(tmp.extra.mark)
+        .text()
+        .then(() => true)
+        .catch(() => false)
+      expect(called).toBe(false)
+      expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
+    } finally {
+      install.mockRestore()
+    }
+  })
+
+  test("skips legacy codex and copilot auth plugin specs", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify(
+            {
+              plugin: ["opencode-openai-codex-auth@1.0.0", "opencode-copilot-auth@1.0.0", "regular-plugin@1.0.0"],
+            },
+            null,
+            2,
+          ),
+        )
+      },
+    })
+
+    const install = spyOn(BunProc, "install").mockResolvedValue("")
+
+    try {
+      await load(tmp.path)
+
+      const pkgs = install.mock.calls.map((call) => call[0])
+      expect(pkgs).toContain("regular-plugin")
+      expect(pkgs).not.toContain("opencode-openai-codex-auth")
+      expect(pkgs).not.toContain("opencode-copilot-auth")
+    } finally {
+      install.mockRestore()
+    }
+  })
+
+  test("publishes session.error when install fails", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2))
+      },
+    })
+
+    const install = spyOn(BunProc, "install").mockRejectedValue(new Error("boom"))
+
+    try {
+      const errors = await errs(tmp.path)
+
+      expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe(
+        true,
+      )
+    } finally {
+      install.mockRestore()
+    }
+  })
+
+  test("publishes session.error when plugin init throws", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = pathToFileURL(path.join(dir, "throws.ts")).href
+        await Bun.write(
+          path.join(dir, "throws.ts"),
+          [
+            "export default {",
+            '  id: "demo.throws",',
+            "  server: async () => {",
+            '    throw new Error("explode")',
+            "  },",
+            "}",
+            "",
+          ].join("\n"),
+        )
+
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
+
+        return { file }
+      },
+    })
+
+    const errors = await errs(tmp.path)
+
+    expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
+  })
+
+  test("publishes session.error when plugin module has invalid export", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = pathToFileURL(path.join(dir, "invalid.ts")).href
+        await Bun.write(
+          path.join(dir, "invalid.ts"),
+          ["export default {", '  id: "demo.invalid",', "  nope: true,", "}", ""].join("\n"),
+        )
+
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
+
+        return { file }
+      },
+    })
+
+    const errors = await errs(tmp.path)
+
+    expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
+  })
+
+  test("publishes session.error when plugin import fails", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
+
+        return { missing }
+      },
+    })
+
+    const errors = await errs(tmp.path)
+
+    expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
+  })
+
+  test("loads object plugin via plugin.server", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = path.join(dir, "object-plugin.ts")
+        const mark = path.join(dir, "object-called.txt")
+        await Bun.write(
+          file,
+          [
+            "const plugin = {",
+            '  id: "demo.object",',
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, \"called\")`,
+            "    return {}",
+            "  },",
+            "}",
+            "export default plugin",
+            "",
+          ].join("\n"),
+        )
+
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+        )
+
+        return { mark }
+      },
+    })
+
+    await load(tmp.path)
+    expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
+  })
+
+  test("passes tuple plugin options into server plugin", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = path.join(dir, "options-plugin.ts")
+        const mark = path.join(dir, "options.json")
+        await Bun.write(
+          file,
+          [
+            "const plugin = {",
+            '  id: "demo.options",',
+            "  server: async (_input, options) => {",
+            `    await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`,
+            "    return {}",
+            "  },",
+            "}",
+            "export default plugin",
+            "",
+          ].join("\n"),
+        )
+
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2),
+        )
+
+        return { mark }
+      },
+    })
+
+    await load(tmp.path)
+    expect(await Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)).toEqual({
+      source: "tuple",
+      enabled: true,
+    })
+  })
+
+  test("skips external plugins in pure mode", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = path.join(dir, "plugin.ts")
+        const mark = path.join(dir, "called.txt")
+        await Bun.write(
+          file,
+          [
+            "export default {",
+            '  id: "demo.pure",',
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, \"called\")`,
+            "    return {}",
+            "  },",
+            "}",
+            "",
+          ].join("\n"),
+        )
+
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+        )
+
+        return { mark }
+      },
+    })
+
+    const pure = process.env.OPENCODE_PURE
+    process.env.OPENCODE_PURE = "1"
+
+    try {
+      await load(tmp.path)
+      const called = await fs
+        .readFile(tmp.extra.mark, "utf8")
+        .then(() => true)
+        .catch(() => false)
+      expect(called).toBe(false)
+    } finally {
+      if (pure === undefined) {
+        delete process.env.OPENCODE_PURE
+      } else {
+        process.env.OPENCODE_PURE = pure
+      }
+    }
+  })
+})

+ 137 - 0
packages/opencode/test/plugin/meta.test.ts

@@ -0,0 +1,137 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+
+import { tmpdir } from "../fixture/fixture"
+import { Process } from "../../src/util/process"
+import { Filesystem } from "../../src/util/filesystem"
+
+const { PluginMeta } = await import("../../src/plugin/meta")
+const root = path.join(import.meta.dir, "../..")
+const worker = path.join(import.meta.dir, "../fixture/plugin-meta-worker.ts")
+
+function run(input: { file: string; spec: string; target: string; id: string }) {
+  return Process.run([process.execPath, worker, JSON.stringify(input)], {
+    cwd: root,
+    nothrow: true,
+  })
+}
+
+async function map<Value>(file: string): Promise<Record<string, Value>> {
+  return Filesystem.readJson<Record<string, Value>>(file)
+}
+
+afterEach(() => {
+  delete process.env.OPENCODE_PLUGIN_META_FILE
+})
+
+describe("plugin.meta", () => {
+  test("tracks file plugin loads and changes", async () => {
+    await using tmp = await tmpdir<{ file: string }>({
+      init: async (dir) => {
+        const file = path.join(dir, "plugin.ts")
+        await Bun.write(file, "export default async () => ({})\n")
+        return { file }
+      },
+    })
+
+    process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
+    const file = process.env.OPENCODE_PLUGIN_META_FILE!
+    const spec = pathToFileURL(tmp.extra.file).href
+
+    const one = await PluginMeta.touch(spec, spec, "demo.file")
+    expect(one.state).toBe("first")
+    expect(one.entry.source).toBe("file")
+    expect(one.entry.id).toBe("demo.file")
+    expect(one.entry.modified).toBeDefined()
+
+    const two = await PluginMeta.touch(spec, spec, "demo.file")
+    expect(two.state).toBe("same")
+    expect(two.entry.load_count).toBe(2)
+
+    await Bun.write(tmp.extra.file, "export default async () => ({ ok: true })\n")
+    const stamp = new Date(Date.now() + 10_000)
+    await fs.utimes(tmp.extra.file, stamp, stamp)
+
+    const three = await PluginMeta.touch(spec, spec, "demo.file")
+    expect(three.state).toBe("updated")
+    expect(three.entry.load_count).toBe(3)
+    expect((three.entry.modified ?? 0) > (one.entry.modified ?? 0)).toBe(true)
+
+    const all = await PluginMeta.list()
+    expect(Object.values(all).some((item) => item.spec === spec && item.source === "file")).toBe(true)
+    const saved = await map<{ spec: string; load_count: number }>(file)
+    expect(saved["demo.file"]?.spec).toBe(spec)
+    expect(saved["demo.file"]?.load_count).toBe(3)
+  })
+
+  test("tracks npm plugin versions", async () => {
+    await using tmp = await tmpdir<{ mod: string; pkg: string }>({
+      init: async (dir) => {
+        const mod = path.join(dir, "node_modules", "acme-plugin")
+        const pkg = path.join(mod, "package.json")
+        await fs.mkdir(mod, { recursive: true })
+        await Bun.write(pkg, JSON.stringify({ name: "acme-plugin", version: "1.0.0" }, null, 2))
+        return { mod, pkg }
+      },
+    })
+
+    process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
+    const file = process.env.OPENCODE_PLUGIN_META_FILE!
+
+    const one = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod, "acme-plugin")
+    expect(one.state).toBe("first")
+    expect(one.entry.source).toBe("npm")
+    expect(one.entry.requested).toBe("latest")
+    expect(one.entry.version).toBe("1.0.0")
+
+    await Bun.write(tmp.extra.pkg, JSON.stringify({ name: "acme-plugin", version: "1.1.0" }, null, 2))
+
+    const two = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod, "acme-plugin")
+    expect(two.state).toBe("updated")
+    expect(two.entry.version).toBe("1.1.0")
+    expect(two.entry.load_count).toBe(2)
+
+    const all = await PluginMeta.list()
+    expect(Object.values(all).some((item) => item.id === "acme-plugin" && item.version === "1.1.0")).toBe(true)
+    const saved = await map<{ id: string; version?: string }>(file)
+    expect(Object.values(saved).some((item) => item.id === "acme-plugin" && item.version === "1.1.0")).toBe(true)
+  })
+
+  test("serializes concurrent metadata updates across processes", async () => {
+    await using tmp = await tmpdir<{ file: string }>({
+      init: async (dir) => {
+        const file = path.join(dir, "plugin.ts")
+        await Bun.write(file, "export default async () => ({})\n")
+        return { file }
+      },
+    })
+
+    process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
+    const file = process.env.OPENCODE_PLUGIN_META_FILE!
+    const spec = pathToFileURL(tmp.extra.file).href
+    const n = 12
+
+    const out = await Promise.all(
+      Array.from({ length: n }, () =>
+        run({
+          file,
+          spec,
+          target: spec,
+          id: "demo.file",
+        }),
+      ),
+    )
+
+    expect(out.map((item) => item.code)).toEqual(Array.from({ length: n }, () => 0))
+    expect(out.map((item) => item.stderr.toString()).filter(Boolean)).toEqual([])
+
+    const all = await PluginMeta.list()
+    const hit = Object.values(all).find((item) => item.spec === spec)
+    expect(hit?.load_count).toBe(n)
+
+    const saved = await map<{ spec: string; load_count: number }>(file)
+    expect(Object.values(saved).find((item) => item.spec === spec)?.load_count).toBe(n)
+  }, 20_000)
+})

+ 38 - 0
packages/opencode/test/util/error.test.ts

@@ -0,0 +1,38 @@
+import { describe, expect, test } from "bun:test"
+import { errorData, errorFormat, errorMessage } from "../../src/util/error"
+
+describe("util.error", () => {
+  test("formats native Error instances", () => {
+    const err = new Error("boom")
+    expect(errorMessage(err)).toBe("boom")
+    expect(errorFormat(err)).toContain("boom")
+
+    const data = errorData(err)
+    expect(data.type).toBe("Error")
+    expect(data.message).toBe("boom")
+    expect(String(data.formatted)).toContain("boom")
+  })
+
+  test("extracts message from record-like values", () => {
+    const err = { message: "bad input", code: "E_BAD" }
+    expect(errorMessage(err)).toBe("bad input")
+
+    const data = errorData(err)
+    expect(data.message).toBe("bad input")
+    expect(data.code).toBe("E_BAD")
+  })
+
+  test("handles opaque throwables with custom toString", () => {
+    const err = {
+      toString() {
+        return "ResolveMessage: Cannot resolve module"
+      },
+    }
+
+    expect(errorMessage(err)).toBe("ResolveMessage: Cannot resolve module")
+
+    const data = errorData(err)
+    expect(data.message).toBe("ResolveMessage: Cannot resolve module")
+    expect(String(data.formatted)).toContain("ResolveMessage")
+  })
+})

+ 383 - 0
packages/opencode/test/util/flock.test.ts

@@ -0,0 +1,383 @@
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { Flock } from "../../src/util/flock"
+import { Hash } from "../../src/util/hash"
+import { Process } from "../../src/util/process"
+import { Filesystem } from "../../src/util/filesystem"
+import { tmpdir } from "../fixture/fixture"
+
+const root = path.join(import.meta.dir, "../..")
+const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts")
+
+type Msg = {
+  key: string
+  dir: string
+  staleMs?: number
+  timeoutMs?: number
+  baseDelayMs?: number
+  maxDelayMs?: number
+  holdMs?: number
+  ready?: string
+  active?: string
+  done?: string
+}
+
+function lock(dir: string, key: string) {
+  return path.join(dir, Hash.fast(key) + ".lock")
+}
+
+function sleep(ms: number) {
+  return new Promise<void>((resolve) => {
+    setTimeout(resolve, ms)
+  })
+}
+
+async function exists(file: string) {
+  return fs
+    .stat(file)
+    .then(() => true)
+    .catch(() => false)
+}
+
+async function wait(file: string, timeout = 3_000) {
+  const stop = Date.now() + timeout
+  while (Date.now() < stop) {
+    if (await exists(file)) return
+    await sleep(20)
+  }
+
+  throw new Error(`Timed out waiting for file: ${file}`)
+}
+
+function run(msg: Msg) {
+  return Process.run([process.execPath, worker, JSON.stringify(msg)], {
+    cwd: root,
+    nothrow: true,
+  })
+}
+
+function spawn(msg: Msg) {
+  return Process.spawn([process.execPath, worker, JSON.stringify(msg)], {
+    cwd: root,
+    stdin: "ignore",
+    stdout: "pipe",
+    stderr: "pipe",
+  })
+}
+
+describe("util.flock", () => {
+  test("enforces mutual exclusion under process contention", async () => {
+    await using tmp = await tmpdir()
+    const dir = path.join(tmp.path, "locks")
+    const done = path.join(tmp.path, "done.log")
+    const active = path.join(tmp.path, "active")
+    const key = "flock:stress"
+    const n = 16
+
+    const out = await Promise.all(
+      Array.from({ length: n }, () =>
+        run({
+          key,
+          dir,
+          done,
+          active,
+          holdMs: 30,
+          staleMs: 1_000,
+          timeoutMs: 15_000,
+        }),
+      ),
+    )
+
+    expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
+    expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
+
+    const lines = (await fs.readFile(done, "utf8"))
+      .split("\n")
+      .map((x) => x.trim())
+      .filter(Boolean)
+    expect(lines.length).toBe(n)
+  }, 20_000)
+
+  test("times out while waiting when lock is still healthy", async () => {
+    await using tmp = await tmpdir()
+    const dir = path.join(tmp.path, "locks")
+    const key = "flock:timeout"
+    const ready = path.join(tmp.path, "ready")
+    const proc = spawn({
+      key,
+      dir,
+      ready,
+      holdMs: 20_000,
+      staleMs: 10_000,
+      timeoutMs: 30_000,
+    })
+
+    try {
+      await wait(ready, 5_000)
+      const seen: string[] = []
+      const err = await Flock.withLock(key, async () => {}, {
+        dir,
+        staleMs: 10_000,
+        timeoutMs: 1_000,
+        onWait: (tick) => {
+          seen.push(tick.key)
+        },
+      }).catch((err) => err)
+
+      expect(err).toBeInstanceOf(Error)
+      if (!(err instanceof Error)) throw err
+      expect(err.message).toContain("Timed out waiting for lock")
+      expect(seen.length).toBeGreaterThan(0)
+      expect(seen.every((x) => x === key)).toBe(true)
+    } finally {
+      await Process.stop(proc).catch(() => undefined)
+      await proc.exited.catch(() => undefined)
+    }
+  }, 15_000)
+
+  test("recovers after a crashed lock owner", async () => {
+    await using tmp = await tmpdir()
+    const dir = path.join(tmp.path, "locks")
+    const key = "flock:crash"
+    const ready = path.join(tmp.path, "ready")
+    const proc = spawn({
+      key,
+      dir,
+      ready,
+      holdMs: 20_000,
+      staleMs: 500,
+      timeoutMs: 30_000,
+    })
+
+    await wait(ready, 5_000)
+    await Process.stop(proc)
+    await proc.exited.catch(() => undefined)
+
+    let hit = false
+    await Flock.withLock(
+      key,
+      async () => {
+        hit = true
+      },
+      {
+        dir,
+        staleMs: 500,
+        timeoutMs: 8_000,
+      },
+    )
+
+    expect(hit).toBe(true)
+  }, 20_000)
+
+  test("breaks stale lock dirs when heartbeat is missing", async () => {
+    await using tmp = await tmpdir()
+    const dir = path.join(tmp.path, "locks")
+    const key = "flock:missing-heartbeat"
+    const lockDir = lock(dir, key)
+
+    await fs.mkdir(lockDir, { recursive: true })
+    const old = new Date(Date.now() - 2_000)
+    await fs.utimes(lockDir, old, old)
+
+    let hit = false
+    await Flock.withLock(
+      key,
+      async () => {
+        hit = true
+      },
+      {
+        dir,
+        staleMs: 200,
+        timeoutMs: 3_000,
+      },
+    )
+
+    expect(hit).toBe(true)
+  })
+
+  test("recovers when a stale breaker claim was left behind", async () => {
+    await using tmp = await tmpdir()
+    const dir = path.join(tmp.path, "locks")
+    const key = "flock:stale-breaker"
+    const lockDir = lock(dir, key)
+    const breaker = lockDir + ".breaker"
+
+    await fs.mkdir(lockDir, { recursive: true })
+    await fs.mkdir(breaker)
+
+    const old = new Date(Date.now() - 2_000)
+    await fs.utimes(lockDir, old, old)
+    await fs.utimes(breaker, old, old)
+
+    let hit = false
+    await Flock.withLock(
+      key,
+      async () => {
+        hit = true
+      },
+      {
+        dir,
+        staleMs: 200,
+        timeoutMs: 3_000,
+      },
+    )
+
+    expect(hit).toBe(true)
+    expect(await exists(breaker)).toBe(false)
+  })
+
+  test("fails clearly if lock dir is removed while held", async () => {
+    await using tmp = await tmpdir()
+    const dir = path.join(tmp.path, "locks")
+    const key = "flock:compromised"
+    const lockDir = lock(dir, key)
+
+    const err = await Flock.withLock(
+      key,
+      async () => {
+        await fs.rm(lockDir, {
+          recursive: true,
+          force: true,
+        })
+      },
+      {
+        dir,
+        staleMs: 1_000,
+        timeoutMs: 3_000,
+      },
+    ).catch((err) => err)
+
+    expect(err).toBeInstanceOf(Error)
+    if (!(err instanceof Error)) throw err
+    expect(err.message).toContain("compromised")
+
+    let hit = false
+    await Flock.withLock(
+      key,
+      async () => {
+        hit = true
+      },
+      {
+        dir,
+        staleMs: 200,
+        timeoutMs: 3_000,
+      },
+    )
+    expect(hit).toBe(true)
+  })
+
+  test("writes owner metadata while lock is held", async () => {
+    await using tmp = await tmpdir()
+    const dir = path.join(tmp.path, "locks")
+    const key = "flock:meta"
+    const file = path.join(lock(dir, key), "meta.json")
+
+    await Flock.withLock(
+      key,
+      async () => {
+        const json = await Filesystem.readJson<{
+          token?: unknown
+          pid?: unknown
+          hostname?: unknown
+          createdAt?: unknown
+        }>(file)
+
+        expect(typeof json.token).toBe("string")
+        expect(typeof json.pid).toBe("number")
+        expect(typeof json.hostname).toBe("string")
+        expect(typeof json.createdAt).toBe("string")
+      },
+      {
+        dir,
+        staleMs: 1_000,
+        timeoutMs: 3_000,
+      },
+    )
+  })
+
+  test("supports acquire with await using", async () => {
+    await using tmp = await tmpdir()
+    const dir = path.join(tmp.path, "locks")
+    const key = "flock:acquire"
+    const lockDir = lock(dir, key)
+
+    {
+      await using _ = await Flock.acquire(key, {
+        dir,
+        staleMs: 1_000,
+        timeoutMs: 3_000,
+      })
+      expect(await exists(lockDir)).toBe(true)
+    }
+
+    expect(await exists(lockDir)).toBe(false)
+  })
+
+  test("refuses token mismatch release and recovers from stale", async () => {
+    await using tmp = await tmpdir()
+    const dir = path.join(tmp.path, "locks")
+    const key = "flock:token"
+    const lockDir = lock(dir, key)
+    const meta = path.join(lockDir, "meta.json")
+
+    const err = await Flock.withLock(
+      key,
+      async () => {
+        const json = await Filesystem.readJson<{ token?: string }>(meta)
+        json.token = "tampered"
+        await fs.writeFile(meta, JSON.stringify(json, null, 2))
+      },
+      {
+        dir,
+        staleMs: 500,
+        timeoutMs: 3_000,
+      },
+    ).catch((err) => err)
+
+    expect(err).toBeInstanceOf(Error)
+    if (!(err instanceof Error)) throw err
+    expect(err.message).toContain("token mismatch")
+    expect(await exists(lockDir)).toBe(true)
+
+    let hit = false
+    await Flock.withLock(
+      key,
+      async () => {
+        hit = true
+      },
+      {
+        dir,
+        staleMs: 500,
+        timeoutMs: 6_000,
+      },
+    )
+    expect(hit).toBe(true)
+  })
+
+  test("fails clearly on unwritable lock roots", async () => {
+    if (process.platform === "win32") return
+
+    await using tmp = await tmpdir()
+    const dir = path.join(tmp.path, "locks")
+    const key = "flock:perm"
+
+    await fs.mkdir(dir, { recursive: true })
+    await fs.chmod(dir, 0o500)
+
+    try {
+      const err = await Flock.withLock(key, async () => {}, {
+        dir,
+        staleMs: 100,
+        timeoutMs: 500,
+      }).catch((err) => err)
+
+      expect(err).toBeInstanceOf(Error)
+      if (!(err instanceof Error)) throw err
+      const text = err.message
+      expect(text.includes("EACCES") || text.includes("EPERM")).toBe(true)
+    } finally {
+      await fs.chmod(dir, 0o700)
+    }
+  })
+})

+ 16 - 1
packages/plugin/package.json

@@ -10,7 +10,8 @@
   },
   "exports": {
     ".": "./src/index.ts",
-    "./tool": "./src/tool.ts"
+    "./tool": "./src/tool.ts",
+    "./tui": "./src/tui.ts"
   },
   "files": [
     "dist"
@@ -19,7 +20,21 @@
     "@opencode-ai/sdk": "workspace:*",
     "zod": "catalog:"
   },
+  "peerDependencies": {
+    "@opentui/core": ">=0.1.90",
+    "@opentui/solid": ">=0.1.90"
+  },
+  "peerDependenciesMeta": {
+    "@opentui/core": {
+      "optional": true
+    },
+    "@opentui/solid": {
+      "optional": true
+    }
+  },
   "devDependencies": {
+    "@opentui/core": "0.1.90",
+    "@opentui/solid": "0.1.90",
     "@tsconfig/node22": "catalog:",
     "@types/node": "catalog:",
     "typescript": "catalog:",

+ 18 - 4
packages/plugin/src/index.ts

@@ -9,7 +9,7 @@ import type {
   Message,
   Part,
   Auth,
-  Config,
+  Config as SDKConfig,
 } from "@opencode-ai/sdk"
 
 import type { BunShell } from "./shell.js"
@@ -32,7 +32,18 @@ export type PluginInput = {
   $: BunShell
 }
 
-export type Plugin = (input: PluginInput) => Promise<Hooks>
+export type PluginOptions = Record<string, unknown>
+
+export type Config = Omit<SDKConfig, "plugin"> & {
+  plugin?: Array<string | [string, PluginOptions]>
+}
+
+export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>
+
+export type PluginModule = {
+  id?: string
+  server?: Plugin
+}
 
 type Rule = {
   key: string
@@ -72,7 +83,7 @@ export type AuthHook = {
               when?: Rule
             }
         >
-        authorize(inputs?: Record<string, string>): Promise<AuthOuathResult>
+        authorize(inputs?: Record<string, string>): Promise<AuthOAuthResult>
       }
     | {
         type: "api"
@@ -116,7 +127,7 @@ export type AuthHook = {
   )[]
 }
 
-export type AuthOuathResult = { url: string; instructions: string } & (
+export type AuthOAuthResult = { url: string; instructions: string } & (
   | {
       method: "auto"
       callback(): Promise<
@@ -161,6 +172,9 @@ export type AuthOuathResult = { url: string; instructions: string } & (
     }
 )
 
+/** @deprecated Use AuthOAuthResult instead. */
+export type AuthOuathResult = AuthOAuthResult
+
 export interface Hooks {
   event?: (input: { event: Event }) => Promise<void>
   config?: (input: Config) => Promise<void>

+ 419 - 0
packages/plugin/src/tui.ts

@@ -0,0 +1,419 @@
+import type {
+  OpencodeClient,
+  Event,
+  LspStatus,
+  McpStatus,
+  Todo,
+  Message,
+  Part,
+  Provider,
+  PermissionRequest,
+  QuestionRequest,
+  SessionStatus,
+  Workspace,
+  Config as SdkConfig,
+} from "@opencode-ai/sdk/v2"
+import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core"
+import type { JSX, SolidPlugin } from "@opentui/solid"
+import type { Config as PluginConfig, Plugin, PluginModule, PluginOptions } from "./index.js"
+
+export type { CliRenderer, SlotMode } from "@opentui/core"
+
+export type TuiRouteCurrent =
+  | {
+      name: "home"
+    }
+  | {
+      name: "session"
+      params: {
+        sessionID: string
+        initialPrompt?: unknown
+      }
+    }
+  | {
+      name: string
+      params?: Record<string, unknown>
+    }
+
+export type TuiRouteDefinition = {
+  name: string
+  render: (input: { params?: Record<string, unknown> }) => JSX.Element
+}
+
+export type TuiCommand = {
+  title: string
+  value: string
+  description?: string
+  category?: string
+  keybind?: string
+  suggested?: boolean
+  hidden?: boolean
+  enabled?: boolean
+  slash?: {
+    name: string
+    aliases?: string[]
+  }
+  onSelect?: () => void
+}
+
+export type TuiKeybind = {
+  name: string
+  ctrl: boolean
+  meta: boolean
+  shift: boolean
+  super?: boolean
+  leader: boolean
+}
+
+export type TuiKeybindMap = Record<string, string>
+
+export type TuiKeybindSet = {
+  readonly all: TuiKeybindMap
+  get: (name: string) => string
+  match: (name: string, evt: ParsedKey) => boolean
+  print: (name: string) => string
+}
+
+export type TuiDialogProps = {
+  size?: "medium" | "large" | "xlarge"
+  onClose: () => void
+  children?: JSX.Element
+}
+
+export type TuiDialogStack = {
+  replace: (render: () => JSX.Element, onClose?: () => void) => void
+  clear: () => void
+  setSize: (size: "medium" | "large" | "xlarge") => void
+  readonly size: "medium" | "large" | "xlarge"
+  readonly depth: number
+  readonly open: boolean
+}
+
+export type TuiDialogAlertProps = {
+  title: string
+  message: string
+  onConfirm?: () => void
+}
+
+export type TuiDialogConfirmProps = {
+  title: string
+  message: string
+  onConfirm?: () => void
+  onCancel?: () => void
+}
+
+export type TuiDialogPromptProps = {
+  title: string
+  description?: () => JSX.Element
+  placeholder?: string
+  value?: string
+  onConfirm?: (value: string) => void
+  onCancel?: () => void
+}
+
+export type TuiDialogSelectOption<Value = unknown> = {
+  title: string
+  value: Value
+  description?: string
+  footer?: JSX.Element | string
+  category?: string
+  disabled?: boolean
+  onSelect?: () => void
+}
+
+export type TuiDialogSelectProps<Value = unknown> = {
+  title: string
+  placeholder?: string
+  options: TuiDialogSelectOption<Value>[]
+  flat?: boolean
+  onMove?: (option: TuiDialogSelectOption<Value>) => void
+  onFilter?: (query: string) => void
+  onSelect?: (option: TuiDialogSelectOption<Value>) => void
+  skipFilter?: boolean
+  current?: Value
+}
+
+export type TuiToast = {
+  variant?: "info" | "success" | "warning" | "error"
+  title?: string
+  message: string
+  duration?: number
+}
+
+export type TuiThemeCurrent = {
+  readonly primary: RGBA
+  readonly secondary: RGBA
+  readonly accent: RGBA
+  readonly error: RGBA
+  readonly warning: RGBA
+  readonly success: RGBA
+  readonly info: RGBA
+  readonly text: RGBA
+  readonly textMuted: RGBA
+  readonly selectedListItemText: RGBA
+  readonly background: RGBA
+  readonly backgroundPanel: RGBA
+  readonly backgroundElement: RGBA
+  readonly backgroundMenu: RGBA
+  readonly border: RGBA
+  readonly borderActive: RGBA
+  readonly borderSubtle: RGBA
+  readonly diffAdded: RGBA
+  readonly diffRemoved: RGBA
+  readonly diffContext: RGBA
+  readonly diffHunkHeader: RGBA
+  readonly diffHighlightAdded: RGBA
+  readonly diffHighlightRemoved: RGBA
+  readonly diffAddedBg: RGBA
+  readonly diffRemovedBg: RGBA
+  readonly diffContextBg: RGBA
+  readonly diffLineNumber: RGBA
+  readonly diffAddedLineNumberBg: RGBA
+  readonly diffRemovedLineNumberBg: RGBA
+  readonly markdownText: RGBA
+  readonly markdownHeading: RGBA
+  readonly markdownLink: RGBA
+  readonly markdownLinkText: RGBA
+  readonly markdownCode: RGBA
+  readonly markdownBlockQuote: RGBA
+  readonly markdownEmph: RGBA
+  readonly markdownStrong: RGBA
+  readonly markdownHorizontalRule: RGBA
+  readonly markdownListItem: RGBA
+  readonly markdownListEnumeration: RGBA
+  readonly markdownImage: RGBA
+  readonly markdownImageText: RGBA
+  readonly markdownCodeBlock: RGBA
+  readonly syntaxComment: RGBA
+  readonly syntaxKeyword: RGBA
+  readonly syntaxFunction: RGBA
+  readonly syntaxVariable: RGBA
+  readonly syntaxString: RGBA
+  readonly syntaxNumber: RGBA
+  readonly syntaxType: RGBA
+  readonly syntaxOperator: RGBA
+  readonly syntaxPunctuation: RGBA
+  readonly thinkingOpacity: number
+}
+
+export type TuiTheme = {
+  readonly current: TuiThemeCurrent
+  readonly selected: string
+  has: (name: string) => boolean
+  set: (name: string) => boolean
+  install: (jsonPath: string) => Promise<void>
+  mode: () => "dark" | "light"
+  readonly ready: boolean
+}
+
+export type TuiKV = {
+  get: <Value = unknown>(key: string, fallback?: Value) => Value
+  set: (key: string, value: unknown) => void
+  readonly ready: boolean
+}
+
+export type TuiState = {
+  readonly ready: boolean
+  readonly config: SdkConfig
+  readonly provider: ReadonlyArray<Provider>
+  readonly path: {
+    state: string
+    config: string
+    worktree: string
+    directory: string
+  }
+  readonly vcs: { branch?: string } | undefined
+  readonly workspace: {
+    list: () => ReadonlyArray<Workspace>
+    get: (workspaceID: string) => Workspace | undefined
+  }
+  session: {
+    count: () => number
+    diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
+    todo: (sessionID: string) => ReadonlyArray<TuiSidebarTodoItem>
+    messages: (sessionID: string) => ReadonlyArray<Message>
+    status: (sessionID: string) => SessionStatus | undefined
+    permission: (sessionID: string) => ReadonlyArray<PermissionRequest>
+    question: (sessionID: string) => ReadonlyArray<QuestionRequest>
+  }
+  part: (messageID: string) => ReadonlyArray<Part>
+  lsp: () => ReadonlyArray<TuiSidebarLspItem>
+  mcp: () => ReadonlyArray<TuiSidebarMcpItem>
+}
+
+type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plugin"> &
+  NonNullable<PluginConfig["tui"]> & {
+    plugin_enabled?: Record<string, boolean>
+  }
+
+export type TuiApp = {
+  readonly version: string
+}
+
+type Frozen<Value> = Value extends (...args: never[]) => unknown
+  ? Value
+  : Value extends ReadonlyArray<infer Item>
+    ? ReadonlyArray<Frozen<Item>>
+    : Value extends object
+      ? { readonly [Key in keyof Value]: Frozen<Value[Key]> }
+      : Value
+
+export type TuiSidebarMcpItem = {
+  name: string
+  status: McpStatus["status"]
+  error?: string
+}
+
+export type TuiSidebarLspItem = Pick<LspStatus, "id" | "root" | "status">
+
+export type TuiSidebarTodoItem = Pick<Todo, "content" | "status">
+
+export type TuiSidebarFileItem = {
+  file: string
+  additions: number
+  deletions: number
+}
+
+export type TuiSlotMap = {
+  app: {}
+  home_logo: {}
+  home_bottom: {}
+  sidebar_title: {
+    session_id: string
+    title: string
+    share_url?: string
+  }
+  sidebar_content: {
+    session_id: string
+  }
+  sidebar_footer: {
+    session_id: string
+  }
+}
+
+export type TuiSlotContext = {
+  theme: TuiTheme
+}
+
+type SlotCore = SolidPlugin<TuiSlotMap, TuiSlotContext>
+
+export type TuiSlotPlugin = Omit<SlotCore, "id"> & {
+  id?: never
+}
+
+export type TuiSlots = {
+  register: (plugin: TuiSlotPlugin) => string
+}
+
+export type TuiEventBus = {
+  on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void
+}
+
+export type TuiDispose = () => void | Promise<void>
+
+export type TuiLifecycle = {
+  readonly signal: AbortSignal
+  onDispose: (fn: TuiDispose) => () => void
+}
+
+export type TuiPluginState = "first" | "updated" | "same"
+
+export type TuiPluginEntry = {
+  id: string
+  source: "file" | "npm" | "internal"
+  spec: string
+  target: string
+  requested?: string
+  version?: string
+  modified?: number
+  first_time: number
+  last_time: number
+  time_changed: number
+  load_count: number
+  fingerprint: string
+}
+
+export type TuiPluginMeta = TuiPluginEntry & {
+  state: TuiPluginState
+}
+
+export type TuiPluginStatus = {
+  id: string
+  source: TuiPluginEntry["source"]
+  spec: string
+  target: string
+  enabled: boolean
+  active: boolean
+}
+
+export type TuiPluginInstallOptions = {
+  global?: boolean
+}
+
+export type TuiPluginInstallResult =
+  | {
+      ok: true
+      dir: string
+      tui: boolean
+    }
+  | {
+      ok: false
+      message: string
+      missing?: boolean
+    }
+
+export type TuiWorkspace = {
+  current: () => string | undefined
+  set: (workspaceID?: string) => void
+}
+
+export type TuiPluginApi = {
+  app: TuiApp
+  command: {
+    register: (cb: () => TuiCommand[]) => () => void
+    trigger: (value: string) => void
+  }
+  route: {
+    register: (routes: TuiRouteDefinition[]) => () => void
+    navigate: (name: string, params?: Record<string, unknown>) => void
+    readonly current: TuiRouteCurrent
+  }
+  ui: {
+    Dialog: (props: TuiDialogProps) => JSX.Element
+    DialogAlert: (props: TuiDialogAlertProps) => JSX.Element
+    DialogConfirm: (props: TuiDialogConfirmProps) => JSX.Element
+    DialogPrompt: (props: TuiDialogPromptProps) => JSX.Element
+    DialogSelect: <Value = unknown>(props: TuiDialogSelectProps<Value>) => JSX.Element
+    toast: (input: TuiToast) => void
+    dialog: TuiDialogStack
+  }
+  keybind: {
+    match: (key: string, evt: ParsedKey) => boolean
+    print: (key: string) => string
+    create: (defaults: TuiKeybindMap, overrides?: Record<string, unknown>) => TuiKeybindSet
+  }
+  readonly tuiConfig: Frozen<TuiConfigView>
+  kv: TuiKV
+  state: TuiState
+  theme: TuiTheme
+  client: OpencodeClient
+  scopedClient: (workspaceID?: string) => OpencodeClient
+  workspace: TuiWorkspace
+  event: TuiEventBus
+  renderer: CliRenderer
+  slots: TuiSlots
+  plugins: {
+    list: () => ReadonlyArray<TuiPluginStatus>
+    activate: (id: string) => Promise<boolean>
+    deactivate: (id: string) => Promise<boolean>
+    add: (spec: string) => Promise<boolean>
+    install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
+  }
+  lifecycle: TuiLifecycle
+}
+
+export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
+
+export type TuiPluginModule = PluginModule & {
+  tui?: TuiPlugin
+}

+ 9 - 1
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -1447,7 +1447,15 @@ export type Config = {
   watcher?: {
     ignore?: Array<string>
   }
-  plugin?: Array<string>
+  plugin?: Array<
+    | string
+    | [
+        string,
+        {
+          [key: string]: unknown
+        },
+      ]
+  >
   /**
    * Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.
    */