Browse Source

Merge dev into refactor/npm-over-bunproc

Dax Raad 3 weeks ago
parent
commit
4d079b34f4
100 changed files with 9758 additions and 1853 deletions
  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. 17 3
      bun.lock
  6. 4 4
      nix/hashes.json
  7. 1 0
      package.json
  8. 3 1
      packages/app/src/components/status-popover-body.tsx
  9. 18 10
      packages/app/src/pages/session.tsx
  10. 2 1
      packages/opencode/.gitignore
  11. 1 1
      packages/opencode/bunfig.toml
  12. 20 12
      packages/opencode/script/build.ts
  13. 17 14
      packages/opencode/specs/effect-migration.md
  14. 377 0
      packages/opencode/specs/tui-plugins.md
  15. 12 7
      packages/opencode/src/agent/agent.ts
  16. 128 0
      packages/opencode/src/bun/index.ts
  17. 50 0
      packages/opencode/src/bun/registry.ts
  18. 3 2
      packages/opencode/src/cli/cmd/db.ts
  19. 231 0
      packages/opencode/src/cli/cmd/plug.ts
  20. 183 182
      packages/opencode/src/cli/cmd/tui/app.tsx
  21. 30 6
      packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
  22. 2 1
      packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
  23. 13 20
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
  24. 91 0
      packages/opencode/src/cli/cmd/tui/component/error-component.tsx
  25. 14 0
      packages/opencode/src/cli/cmd/tui/component/plugin-route-missing.tsx
  26. 63 0
      packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx
  27. 2 1
      packages/opencode/src/cli/cmd/tui/context/exit.tsx
  28. 12 9
      packages/opencode/src/cli/cmd/tui/context/keybind.tsx
  29. 41 0
      packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts
  30. 7 2
      packages/opencode/src/cli/cmd/tui/context/route.tsx
  31. 3 0
      packages/opencode/src/cli/cmd/tui/context/sdk.tsx
  32. 132 110
      packages/opencode/src/cli/cmd/tui/context/theme.tsx
  33. 1 1
      packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx
  34. 48 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx
  35. 61 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
  36. 60 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx
  37. 91 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx
  38. 64 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx
  39. 94 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx
  40. 46 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx
  41. 262 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx
  42. 406 0
      packages/opencode/src/cli/cmd/tui/plugin/api.tsx
  43. 3 0
      packages/opencode/src/cli/cmd/tui/plugin/index.ts
  44. 25 0
      packages/opencode/src/cli/cmd/tui/plugin/internal.ts
  45. 972 0
      packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
  46. 61 0
      packages/opencode/src/cli/cmd/tui/plugin/slots.tsx
  47. 9 39
      packages/opencode/src/cli/cmd/tui/routes/home.tsx
  48. 0 1
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  49. 25 278
      packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
  50. 3 2
      packages/opencode/src/cli/cmd/tui/thread.ts
  51. 14 4
      packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
  52. 2 13
      packages/opencode/src/cli/error.ts
  53. 19 9
      packages/opencode/src/command/index.ts
  54. 488 345
      packages/opencode/src/config/config.ts
  55. 2 0
      packages/opencode/src/config/tui-schema.ts
  56. 121 27
      packages/opencode/src/config/tui.ts
  57. 4 1
      packages/opencode/src/effect/cross-spawn-spawner.ts
  58. 6 2
      packages/opencode/src/file/watcher.ts
  59. 25 0
      packages/opencode/src/flag/flag.ts
  60. 6 2
      packages/opencode/src/format/index.ts
  61. 14 3
      packages/opencode/src/index.ts
  62. 1 4
      packages/opencode/src/installation/index.ts
  63. 6 2
      packages/opencode/src/lsp/index.ts
  64. 10 12
      packages/opencode/src/mcp/index.ts
  65. 146 45
      packages/opencode/src/plugin/index.ts
  66. 351 0
      packages/opencode/src/plugin/install.ts
  67. 165 0
      packages/opencode/src/plugin/meta.ts
  68. 149 0
      packages/opencode/src/plugin/shared.ts
  69. 9 10
      packages/opencode/src/project/project.ts
  70. 26 13
      packages/opencode/src/project/vcs.ts
  71. 3 3
      packages/opencode/src/provider/auth.ts
  72. 1 1
      packages/opencode/src/pty/index.ts
  73. 2 1
      packages/opencode/src/session/message-v2.ts
  74. 96 50
      packages/opencode/src/skill/index.ts
  75. 366 372
      packages/opencode/src/snapshot/index.ts
  76. 2 1
      packages/opencode/src/tool/batch.ts
  77. 61 58
      packages/opencode/src/tool/registry.ts
  78. 77 0
      packages/opencode/src/util/error.ts
  79. 333 0
      packages/opencode/src/util/flock.ts
  80. 6 0
      packages/opencode/src/util/network.ts
  81. 2 1
      packages/opencode/src/util/process.ts
  82. 3 0
      packages/opencode/src/util/record.ts
  83. 24 17
      packages/opencode/src/worktree/index.ts
  84. 90 0
      packages/opencode/test/cli/tui/keybind-plugin.test.ts
  85. 61 0
      packages/opencode/test/cli/tui/plugin-add.test.ts
  86. 95 0
      packages/opencode/test/cli/tui/plugin-install.test.ts
  87. 225 0
      packages/opencode/test/cli/tui/plugin-lifecycle.test.ts
  88. 132 0
      packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
  89. 71 0
      packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
  90. 563 0
      packages/opencode/test/cli/tui/plugin-loader.test.ts
  91. 157 0
      packages/opencode/test/cli/tui/plugin-toggle.test.ts
  92. 51 0
      packages/opencode/test/cli/tui/theme-store.test.ts
  93. 283 147
      packages/opencode/test/config/config.test.ts
  94. 159 2
      packages/opencode/test/config/tui.test.ts
  95. 1 1
      packages/opencode/test/effect/cross-spawn-spawner.test.ts
  96. 2 0
      packages/opencode/test/file/watcher.test.ts
  97. 72 0
      packages/opencode/test/fixture/flock-worker.ts
  98. 93 0
      packages/opencode/test/fixture/plug-worker.ts
  99. 26 0
      packages/opencode/test/fixture/plugin-meta-worker.ts
  100. 334 0
      packages/opencode/test/fixture/tui-plugin.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"
+        }
+      }
+    ]
+  ]
+}

+ 17 - 3
bun.lock

@@ -430,11 +430,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",
@@ -518,6 +528,7 @@
         "motion-dom": "12.34.3",
         "motion-utils": "12.29.2",
         "remeda": "catalog:",
+        "remend": "catalog:",
         "shiki": "catalog:",
         "solid-js": "catalog:",
         "solid-list": "catalog:",
@@ -633,6 +644,7 @@
     "marked": "17.0.1",
     "marked-shiki": "1.2.1",
     "remeda": "2.26.0",
+    "remend": "1.3.0",
     "shiki": "3.20.0",
     "solid-js": "1.9.10",
     "solid-list": "0.3.0",
@@ -3931,7 +3943,7 @@
 
     "pagefind": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
 
@@ -4213,6 +4225,8 @@
 
     "remeda": ["[email protected]", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
 
+    "remend": ["[email protected]", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="],
+
     "request-light": ["[email protected]", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="],
 
     "require-directory": ["[email protected]", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
@@ -5801,12 +5815,12 @@
 
     "type-is/mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
 
+    "unicode-trie/pako": ["[email protected]", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
+
     "unifont/ofetch": ["[email protected]", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
 
     "uri-js/punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
 
-    "utif2/pako": ["[email protected]", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
-
     "vite-plugin-icons-spritesheet/glob": ["[email protected]", "", { "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/[email protected]", "", { "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=="],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-YI/VXZYi/5BEKRGWCHVqEBmMgBP5VMVJyL06OJlfQxY=",
-    "aarch64-linux": "sha256-HvGPC4TuLnCNAty8nr+JwnPkV+MtrPnso3VPmgCe06Y=",
-    "aarch64-darwin": "sha256-DKzYPvFsKy8utQZbiWWPWukPEle/SuFQz1FakWzObA8=",
-    "x86_64-darwin": "sha256-311yDcV1P3gaFh75j3uoe3eTuZJn48E7OVgNjLxSpIo="
+    "x86_64-linux": "sha256-a2eTu0ISjqPuojkNPnPXzVb/PLlDvw/DXDvmxi9RD5k=",
+    "aarch64-linux": "sha256-yLaTXRzZ7M/6j2WDP+IL1YCY3+rYY4Qmq3xTDatNzD0=",
+    "aarch64-darwin": "sha256-uGSVe8S/QvnW+RCI/CxzrlfAAJ1YA+NrhzRE0GTcnvE=",
+    "x86_64-darwin": "sha256-tplWx2tLg6jWvOBmM41lODJV8pHpkAm4HKWRG7lpkcU="
   }
 }

+ 1 - 0
package.json

@@ -53,6 +53,7 @@
       "luxon": "3.6.1",
       "marked": "17.0.1",
       "marked-shiki": "1.2.1",
+      "remend": "1.3.0",
       "@playwright/test": "1.51.0",
       "typescript": "5.8.2",
       "@typescript/native-preview": "7.0.0-dev.20251207.1",

+ 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"))
 

+ 18 - 10
packages/app/src/pages/session.tsx

@@ -57,12 +57,15 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
 import { useSessionCommands } from "@/pages/session/use-session-commands"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
 import { Identifier } from "@/utils/id"
+import { Persist, persisted } from "@/utils/persist"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { same } from "@/utils/same"
 import { formatServerError } from "@/utils/server-errors"
 
 const emptyUserMessages: UserMessage[] = []
-const emptyFollowups: (FollowupDraft & { id: string })[] = []
+type FollowupItem = FollowupDraft & { id: string }
+type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
+const emptyFollowups: FollowupItem[] = []
 
 type SessionHistoryWindowInput = {
   sessionID: () => string | undefined
@@ -512,15 +515,20 @@ export default function Page() {
     deferRender: false,
   })
 
-  const [followup, setFollowup] = createStore({
-    items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
-    failed: {} as Record<string, string | undefined>,
-    paused: {} as Record<string, boolean | undefined>,
-    edit: {} as Record<
-      string,
-      { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] } | undefined
-    >,
-  })
+  const [followup, setFollowup] = persisted(
+    Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
+    createStore<{
+      items: Record<string, FollowupItem[] | undefined>
+      failed: Record<string, string | undefined>
+      paused: Record<string, boolean | undefined>
+      edit: Record<string, FollowupEdit | undefined>
+    }>({
+      items: {},
+      failed: {},
+      paused: {},
+      edit: {},
+    }),
+  )
 
   createComputed((prev) => {
     const key = sessionKey()

+ 2 - 1
packages/opencode/.gitignore

@@ -2,4 +2,5 @@ research
 dist
 gen
 app.log
-src/provider/models-snapshot.ts
+src/provider/models-snapshot.js
+src/provider/models-snapshot.d.ts

+ 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

+ 20 - 12
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,22 +63,30 @@ 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 () => {
   console.log(`Building Web UI to embed in the binary`)
   const appDir = path.join(import.meta.dirname, "../../app")
+  const dist = path.join(appDir, "dist")
   await $`bun run --cwd ${appDir} build`
-  const allFiles = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: path.join(appDir, "dist") }))
-  const fileMap = `
-    // Import all files as file_$i with type: "file" 
-    ${allFiles.map((filePath, i) => `import file_${i} from "${path.join(appDir, "dist", filePath)}" with { type: "file" };`).join("\n")}
-    // Export with original mappings
-    export default {
-      ${allFiles.map((filePath, i) => `"${filePath}": file_${i},`).join("\n")}
-    }
-    `.trim()
-  return fileMap
+  const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
+    .map((file) => file.replaceAll("\\", "/"))
+    .sort()
+  const imports = files.map((file, i) => {
+    const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/")
+    return `import file_${i} from ${JSON.stringify(spec.startsWith(".") ? spec : `./${spec}`)} with { type: "file" };`
+  })
+  const entries = files.map((file, i) => `  ${JSON.stringify(file)}: file_${i},`)
+  return [
+    `// Import all files as file_$i with type: "file"`,
+    ...imports,
+    `// Export with original mappings`,
+    `export default {`,
+    ...entries,
+    `}`,
+  ].join("\n")
 }
 
 const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle()
@@ -200,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,

+ 17 - 14
packages/opencode/specs/effect-migration.md

@@ -8,8 +8,8 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
 
 Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
 
-- Global services (no per-directory state): Account, Auth, Installation, Truncate
-- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
+- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
+- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
 
 Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
 
@@ -181,36 +181,39 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
 Fully migrated (single namespace, InstanceState where needed, flattened facade):
 
 - [x] `Account` — `account/index.ts`
+- [x] `Agent` — `agent/agent.ts`
+- [x] `AppFileSystem` — `filesystem/index.ts`
 - [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
+- [x] `Bus` — `bus/index.ts`
+- [x] `Command` — `command/index.ts`
+- [x] `Config` — `config/config.ts`
+- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime)
 - [x] `File` — `file/index.ts`
 - [x] `FileTime` — `file/time.ts`
 - [x] `FileWatcher` — `file/watcher.ts`
 - [x] `Format` — `format/index.ts`
 - [x] `Installation` — `installation/index.ts`
+- [x] `LSP` — `lsp/index.ts`
+- [x] `MCP` — `mcp/index.ts`
+- [x] `McpAuth` — `mcp/auth.ts`
 - [x] `Permission` — `permission/index.ts`
+- [x] `Plugin` — `plugin/index.ts`
+- [x] `Project` — `project/project.ts`
 - [x] `ProviderAuth` — `provider/auth.ts`
+- [x] `Pty` — `pty/index.ts`
 - [x] `Question` — `question/index.ts`
+- [x] `SessionStatus` — `session/status.ts`
 - [x] `Skill` — `skill/index.ts`
 - [x] `Snapshot` — `snapshot/index.ts`
+- [x] `ToolRegistry` — `tool/registry.ts`
 - [x] `Truncate` — `tool/truncate.ts`
 - [x] `Vcs` — `project/vcs.ts`
-- [x] `Discovery` — `skill/discovery.ts`
-- [x] `SessionStatus`
+- [x] `Worktree` — `worktree/index.ts`
 
 Still open and likely worth migrating:
 
-- [x] `Plugin`
-- [x] `ToolRegistry`
-- [ ] `Pty`
-- [x] `Worktree`
-- [x] `Bus`
-- [x] `Command`
-- [x] `Config`
 - [ ] `Session`
 - [ ] `SessionProcessor`
 - [ ] `SessionPrompt`
 - [ ] `SessionCompaction`
 - [ ] `Provider`
-- [x] `Project`
-- [x] `LSP`
-- [x] `MCP`

+ 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/[email protected]", ["./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`

+ 12 - 7
packages/opencode/src/agent/agent.ts

@@ -72,13 +72,14 @@ export namespace Agent {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
-      const config = () => Effect.promise(() => Config.get())
+      const config = yield* Config.Service
       const auth = yield* Auth.Service
+      const skill = yield* Skill.Service
 
       const state = yield* InstanceState.make<State>(
         Effect.fn("Agent.state")(function* (ctx) {
-          const cfg = yield* config()
-          const skillDirs = yield* Effect.promise(() => Skill.dirs())
+          const cfg = yield* config.get()
+          const skillDirs = yield* skill.dirs()
           const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
 
           const defaults = Permission.fromConfig({
@@ -281,7 +282,7 @@ export namespace Agent {
           })
 
           const list = Effect.fnUntraced(function* () {
-            const cfg = yield* config()
+            const cfg = yield* config.get()
             return pipe(
               agents,
               values(),
@@ -293,7 +294,7 @@ export namespace Agent {
           })
 
           const defaultAgent = Effect.fnUntraced(function* () {
-            const c = yield* config()
+            const c = yield* config.get()
             if (c.default_agent) {
               const agent = agents[c.default_agent]
               if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
@@ -328,7 +329,7 @@ export namespace Agent {
           description: string
           model?: { providerID: ProviderID; modelID: ModelID }
         }) {
-          const cfg = yield* config()
+          const cfg = yield* config.get()
           const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
           const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
           const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
@@ -391,7 +392,11 @@ export namespace Agent {
     }),
   )
 
-  export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
+  export const defaultLayer = layer.pipe(
+    Layer.provide(Auth.layer),
+    Layer.provide(Config.defaultLayer),
+    Layer.provide(Skill.defaultLayer),
+  )
 
   const { runPromise } = makeRuntime(Service, defaultLayer)
 

+ 128 - 0
packages/opencode/src/bun/index.ts

@@ -0,0 +1,128 @@
+import z from "zod"
+import { Global } from "../global"
+import { Log } from "../util/log"
+import path from "path"
+import { Filesystem } from "../util/filesystem"
+import { NamedError } from "@opencode-ai/util/error"
+import { Lock } from "../util/lock"
+import { PackageRegistry } from "./registry"
+import { online, proxied } from "@/util/network"
+import { Process } from "../util/process"
+
+export namespace BunProc {
+  const log = Log.create({ service: "bun" })
+
+  export async function run(cmd: string[], options?: Process.RunOptions) {
+    const full = [which(), ...cmd]
+    log.info("running", {
+      cmd: full,
+      ...options,
+    })
+    const result = await Process.run(full, {
+      cwd: options?.cwd,
+      abort: options?.abort,
+      kill: options?.kill,
+      timeout: options?.timeout,
+      nothrow: options?.nothrow,
+      env: {
+        ...process.env,
+        ...options?.env,
+        BUN_BE_BUN: "1",
+      },
+    })
+    log.info("done", {
+      code: result.code,
+      stdout: result.stdout.toString(),
+      stderr: result.stderr.toString(),
+    })
+    return result
+  }
+
+  export function which() {
+    return process.execPath
+  }
+
+  export const InstallFailedError = NamedError.create(
+    "BunInstallFailedError",
+    z.object({
+      pkg: z.string(),
+      version: z.string(),
+    }),
+  )
+
+  export async function install(pkg: string, version = "latest") {
+    // Use lock to ensure only one install at a time
+    using _ = await Lock.write("bun-install")
+
+    const mod = path.join(Global.Path.cache, "node_modules", pkg)
+    const pkgjsonPath = path.join(Global.Path.cache, "package.json")
+    const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
+      const result = { dependencies: {} as Record<string, string> }
+      await Filesystem.writeJson(pkgjsonPath, result)
+      return result
+    })
+    if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
+    const dependencies = parsed.dependencies
+    const modExists = await Filesystem.exists(mod)
+    const cachedVersion = dependencies[pkg]
+
+    if (!modExists || !cachedVersion) {
+      // continue to install
+    } else if (version === "latest") {
+      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
+    const args = [
+      "add",
+      "--force",
+      "--exact",
+      // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
+      ...(proxied() || process.env.CI ? ["--no-cache"] : []),
+      "--cwd",
+      Global.Path.cache,
+      pkg + "@" + version,
+    ]
+
+    // Let Bun handle registry resolution:
+    // - If .npmrc files exist, Bun will use them automatically
+    // - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
+    // - No need to pass --registry flag
+    log.info("installing package using Bun's default registry resolution", {
+      pkg,
+      version,
+    })
+
+    await BunProc.run(args, {
+      cwd: Global.Path.cache,
+    }).catch((e) => {
+      throw new InstallFailedError(
+        { pkg, version },
+        {
+          cause: e,
+        },
+      )
+    })
+
+    // Resolve actual version from installed package when using "latest"
+    // This ensures subsequent starts use the cached version until explicitly updated
+    let resolvedVersion = version
+    if (version === "latest") {
+      const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
+        () => null,
+      )
+      if (installedPkg?.version) {
+        resolvedVersion = installedPkg.version
+      }
+    }
+
+    parsed.dependencies[pkg] = resolvedVersion
+    await Filesystem.writeJson(pkgjsonPath, parsed)
+    return mod
+  }
+}

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

@@ -0,0 +1,50 @@
+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" })
+
+  function which() {
+    return process.execPath
+  }
+
+  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: {
+        ...process.env,
+        BUN_BE_BUN: "1",
+      },
+      nothrow: true,
+    })
+
+    if (code !== 0) {
+      log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() })
+      return null
+    }
+
+    const value = stdout.toString().trim()
+    if (!value) return null
+    return value
+  }
+
+  export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
+    const latestVersion = await info(pkg, "version", cwd)
+    if (!latestVersion) {
+      log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
+      return false
+    }
+
+    const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
+    if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
+
+    return semver.lt(cachedVersion, latestVersion)
+  }
+}

+ 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)
 }

+ 19 - 9
packages/opencode/src/command/index.ts

@@ -75,8 +75,12 @@ export namespace Command {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const config = yield* Config.Service
+      const mcp = yield* MCP.Service
+      const skill = yield* Skill.Service
+
       const init = Effect.fn("Command.state")(function* (ctx) {
-        const cfg = yield* Effect.promise(() => Config.get())
+        const cfg = yield* config.get()
         const commands: Record<string, Info> = {}
 
         commands[Default.INIT] = {
@@ -114,7 +118,7 @@ export namespace Command {
           }
         }
 
-        for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
+        for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
           commands[name] = {
             name,
             source: "mcp",
@@ -139,14 +143,14 @@ export namespace Command {
           }
         }
 
-        for (const skill of yield* Effect.promise(() => Skill.all())) {
-          if (commands[skill.name]) continue
-          commands[skill.name] = {
-            name: skill.name,
-            description: skill.description,
+        for (const item of yield* skill.all()) {
+          if (commands[item.name]) continue
+          commands[item.name] = {
+            name: item.name,
+            description: item.description,
             source: "skill",
             get template() {
-              return skill.content
+              return item.content
             },
             hints: [],
           }
@@ -173,7 +177,13 @@ export namespace Command {
     }),
   )
 
-  const { runPromise } = makeRuntime(Service, layer)
+  export const defaultLayer = layer.pipe(
+    Layer.provide(Config.defaultLayer),
+    Layer.provide(MCP.defaultLayer),
+    Layer.provide(Skill.defaultLayer),
+  )
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function get(name: string) {
     return runPromise((svc) => svc.get(name))

+ 488 - 345
packages/opencode/src/config/config.ts

@@ -21,6 +21,7 @@ import {
 } from "jsonc-parser"
 import { Instance, type InstanceContext } from "../project/instance"
 import { LSPServer } from "../lsp/server"
+import { BunProc } from "@/bun"
 import { Installation } from "@/installation"
 import { ConfigMarkdown } from "./markdown"
 import { constants, existsSync } from "fs"
@@ -28,20 +29,28 @@ import { Bus } from "@/bus"
 import { GlobalBus } from "@/bus/global"
 import { Event } from "../server/event"
 import { Glob } from "../util/glob"
+import { PackageRegistry } from "@/bun/registry"
+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 { Npm } from "@/npm"
 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, ServiceMap } from "effect"
+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" })
 
@@ -76,12 +85,88 @@ export namespace Config {
     return merged
   }
 
-  export async function installDependencies(dir: string) {
-    if (!(await isWritable(dir))) {
-      log.info("config dir is not writable, skipping dependency install", { dir })
-      return
+  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 target = Installation.isLocal() ? "*" : Installation.VERSION
+
+    const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
+      dependencies: {},
+    }))
+    json.dependencies = {
+      ...json.dependencies,
+      "@opencode-ai/plugin": target,
     }
-    await Npm.install(dir)
+    await Filesystem.writeJson(pkg, json)
+
+    const gitignore = path.join(dir, ".gitignore")
+    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
+
+    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,
+        abort: input?.signal,
+      },
+    ).catch((err) => {
+      if (err instanceof Process.RunFailedError) {
+        const detail = {
+          dir,
+          cmd: err.cmd,
+          code: err.code,
+          stdout: err.stdout.toString(),
+          stderr: err.stderr.toString(),
+        }
+        if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
+          log.error("failed to install dependencies", detail)
+          throw err
+        }
+        log.warn("failed to install dependencies", detail)
+        return
+      }
+
+      if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
+        log.error("failed to install dependencies", { dir, error: err })
+        throw err
+      }
+      log.warn("failed to install dependencies", { dir, error: err })
+    })
   }
 
   async function isWritable(dir: string) {
@@ -93,6 +178,42 @@ export namespace Config {
     }
   }
 
+  export async function needsInstall(dir: string) {
+    // Some config dirs may be read-only.
+    // Installing deps there will fail; skip installation in that case.
+    const writable = await isWritable(dir)
+    if (!writable) {
+      log.debug("config dir is not writable, skipping dependency install", { dir })
+      return false
+    }
+
+    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)
+    if (!pkgExists) return true
+
+    const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
+    const dependencies = parsed?.dependencies ?? {}
+    const depVersion = dependencies["@opencode-ai/plugin"]
+    if (!depVersion) return true
+
+    const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
+    if (targetVersion === "latest") {
+      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,
+      })
+      return true
+    }
+    if (depVersion === targetVersion) return false
+    return true
+  }
+
   function rel(item: string, patterns: string[]) {
     const normalizedItem = item.replaceAll("\\", "/")
     for (const pattern of patterns) {
@@ -221,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,
@@ -234,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("[email protected]") // "oh-my-opencode"
-   * getPluginName("@scope/[email protected]") // "@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
     }
-    const lastAt = plugin.lastIndexOf("@")
-    if (lastAt > 0) {
-      return plugin.substring(0, lastAt)
+    if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) {
+      const base = pathToFileURL(spec).href
+      const resolved = await resolvePathPluginTarget(base).catch(() => base)
+      if (Array.isArray(plugin)) return [resolved, plugin[1]]
+      return resolved
+    }
+    try {
+      const base = import.meta.resolve!(spec, configFilepath)
+      const resolved = await resolvePathPluginTarget(base).catch(() => base)
+      if (Array.isArray(plugin)) return [resolved, plugin[1]]
+      return resolved
+    } catch {
+      try {
+        const require = createRequire(configFilepath)
+        const base = pathToFileURL(require.resolve(spec)).href
+        const resolved = await resolvePathPluginTarget(base).catch(() => base)
+        if (Array.isArray(plugin)) return [resolved, plugin[1]]
+        return resolved
+      } catch {
+        return plugin
+      }
     }
-    return plugin
   }
 
   /**
@@ -266,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., "[email protected]", "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)
@@ -675,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()
@@ -776,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()
@@ -988,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, {
@@ -1054,369 +1187,379 @@ export namespace Config {
     }),
   )
 
-  export const layer: Layer.Layer<Service, never, AppFileSystem.Service> = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const fs = yield* AppFileSystem.Service
-
-      const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
-        return yield* fs.readFileString(filepath).pipe(
-          Effect.catchIf(
-            (e) => e.reason._tag === "NotFound",
-            () => Effect.succeed(undefined),
-          ),
-          Effect.orDie,
-        )
-      })
+  export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> =
+    Layer.effect(
+      Service,
+      Effect.gen(function* () {
+        const fs = yield* AppFileSystem.Service
+        const authSvc = yield* Auth.Service
+        const accountSvc = yield* Account.Service
+
+        const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
+          return yield* fs.readFileString(filepath).pipe(
+            Effect.catchIf(
+              (e) => e.reason._tag === "NotFound",
+              () => Effect.succeed(undefined),
+            ),
+            Effect.orDie,
+          )
+        })
 
-      const loadConfig = Effect.fnUntraced(function* (
-        text: string,
-        options: { path: string } | { dir: string; source: string },
-      ) {
-        const original = text
-        const source = "path" in options ? options.path : options.source
-        const isFile = "path" in options
-        const data = yield* Effect.promise(() =>
-          ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
-        )
+        const loadConfig = Effect.fnUntraced(function* (
+          text: string,
+          options: { path: string } | { dir: string; source: string },
+        ) {
+          const original = text
+          const source = "path" in options ? options.path : options.source
+          const isFile = "path" in options
+          const data = yield* Effect.promise(() =>
+            ConfigPaths.parseText(
+              text,
+              "path" in options ? options.path : { source: options.source, dir: options.dir },
+            ),
+          )
 
-        const normalized = (() => {
-          if (!data || typeof data !== "object" || Array.isArray(data)) return data
-          const copy = { ...(data as Record<string, unknown>) }
-          const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
-          if (!hadLegacy) return copy
-          delete copy.theme
-          delete copy.keybinds
-          delete copy.tui
-          log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
-          return copy
-        })()
-
-        const parsed = Info.safeParse(normalized)
-        if (parsed.success) {
-          if (!parsed.data.$schema && isFile) {
-            parsed.data.$schema = "https://opencode.ai/config.json"
-            const updated = original.replace(/^\s*\{/, '{\n  "$schema": "https://opencode.ai/config.json",')
-            yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
-          }
-          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 normalized = (() => {
+            if (!data || typeof data !== "object" || Array.isArray(data)) return data
+            const copy = { ...(data as Record<string, unknown>) }
+            const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
+            if (!hadLegacy) return copy
+            delete copy.theme
+            delete copy.keybinds
+            delete copy.tui
+            log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
+            return copy
+          })()
+
+          const parsed = Info.safeParse(normalized)
+          if (parsed.success) {
+            if (!parsed.data.$schema && isFile) {
+              parsed.data.$schema = "https://opencode.ai/config.json"
+              const updated = original.replace(/^\s*\{/, '{\n  "$schema": "https://opencode.ai/config.json",')
+              yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
+            }
+            const data = parsed.data
+            if (data.plugin && isFile) {
+              const list = data.plugin
+              for (let i = 0; i < list.length; i++) {
+                list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
               }
             }
+            return data
           }
-          return data
-        }
 
-        throw new InvalidError({
-          path: source,
-          issues: parsed.error.issues,
+          throw new InvalidError({
+            path: source,
+            issues: parsed.error.issues,
+          })
         })
-      })
-
-      const loadFile = Effect.fnUntraced(function* (filepath: string) {
-        log.info("loading", { path: filepath })
-        const text = yield* readConfigFile(filepath)
-        if (!text) return {} as Info
-        return yield* loadConfig(text, { path: filepath })
-      })
 
-      const loadGlobal = Effect.fnUntraced(function* () {
-        let result: Info = pipe(
-          {},
-          mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
-          mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
-          mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
-        )
+        const loadFile = Effect.fnUntraced(function* (filepath: string) {
+          log.info("loading", { path: filepath })
+          const text = yield* readConfigFile(filepath)
+          if (!text) return {} as Info
+          return yield* loadConfig(text, { path: filepath })
+        })
 
-        const legacy = path.join(Global.Path.config, "config")
-        if (existsSync(legacy)) {
-          yield* Effect.promise(() =>
-            import(pathToFileURL(legacy).href, { with: { type: "toml" } })
-              .then(async (mod) => {
-                const { provider, model, ...rest } = mod.default
-                if (provider && model) result.model = `${provider}/${model}`
-                result["$schema"] = "https://opencode.ai/config.json"
-                result = mergeDeep(result, rest)
-                await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
-                await fsNode.unlink(legacy)
-              })
-              .catch(() => {}),
+        const loadGlobal = Effect.fnUntraced(function* () {
+          let result: Info = pipe(
+            {},
+            mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
+            mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
+            mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
           )
-        }
 
-        return result
-      })
+          const legacy = path.join(Global.Path.config, "config")
+          if (existsSync(legacy)) {
+            yield* Effect.promise(() =>
+              import(pathToFileURL(legacy).href, { with: { type: "toml" } })
+                .then(async (mod) => {
+                  const { provider, model, ...rest } = mod.default
+                  if (provider && model) result.model = `${provider}/${model}`
+                  result["$schema"] = "https://opencode.ai/config.json"
+                  result = mergeDeep(result, rest)
+                  await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
+                  await fsNode.unlink(legacy)
+                })
+                .catch(() => {}),
+            )
+          }
 
-      const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
-        loadGlobal().pipe(
-          Effect.tapError((error) =>
-            Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
+          return result
+        })
+
+        const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
+          loadGlobal().pipe(
+            Effect.tapError((error) =>
+              Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
+            ),
+            Effect.orElseSucceed((): Info => ({})),
           ),
-          Effect.orElseSucceed((): Info => ({})),
-        ),
-        Duration.infinity,
-      )
+          Duration.infinity,
+        )
 
-      const getGlobal = Effect.fn("Config.getGlobal")(function* () {
-        return yield* cachedGlobal
-      })
+        const getGlobal = Effect.fn("Config.getGlobal")(function* () {
+          return yield* cachedGlobal
+        })
 
-      const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
-        const auth = yield* Effect.promise(() => Auth.all())
-
-        let result: Info = {}
-        for (const [key, value] of Object.entries(auth)) {
-          if (value.type === "wellknown") {
-            const url = key.replace(/\/+$/, "")
-            process.env[value.key] = value.token
-            log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
-            const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
-            if (!response.ok) {
-              throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
+        const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
+          const auth = yield* authSvc.all().pipe(Effect.orDie)
+
+          let result: Info = {}
+          for (const [key, value] of Object.entries(auth)) {
+            if (value.type === "wellknown") {
+              const url = key.replace(/\/+$/, "")
+              process.env[value.key] = value.token
+              log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
+              const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
+              if (!response.ok) {
+                throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
+              }
+              const wellknown = (yield* Effect.promise(() => response.json())) as any
+              const remoteConfig = wellknown.config ?? {}
+              if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
+              result = mergeConfigConcatArrays(
+                result,
+                yield* loadConfig(JSON.stringify(remoteConfig), {
+                  dir: path.dirname(`${url}/.well-known/opencode`),
+                  source: `${url}/.well-known/opencode`,
+                }),
+              )
+              log.debug("loaded remote config from well-known", { url })
             }
-            const wellknown = (yield* Effect.promise(() => response.json())) as any
-            const remoteConfig = wellknown.config ?? {}
-            if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
-            result = mergeConfigConcatArrays(
-              result,
-              yield* loadConfig(JSON.stringify(remoteConfig), {
-                dir: path.dirname(`${url}/.well-known/opencode`),
-                source: `${url}/.well-known/opencode`,
-              }),
-            )
-            log.debug("loaded remote config from well-known", { url })
           }
-        }
 
-        result = mergeConfigConcatArrays(result, yield* getGlobal())
+          result = mergeConfigConcatArrays(result, yield* getGlobal())
 
-        if (Flag.OPENCODE_CONFIG) {
-          result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
-          log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
-        }
+          if (Flag.OPENCODE_CONFIG) {
+            result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
+            log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
+          }
 
-        if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
-          for (const file of yield* Effect.promise(() =>
-            ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
-          )) {
-            result = mergeConfigConcatArrays(result, yield* loadFile(file))
+          if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+            for (const file of yield* Effect.promise(() =>
+              ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
+            )) {
+              result = mergeConfigConcatArrays(result, yield* loadFile(file))
+            }
           }
-        }
 
-        result.agent = result.agent || {}
-        result.mode = result.mode || {}
-        result.plugin = result.plugin || []
+          result.agent = result.agent || {}
+          result.mode = result.mode || {}
+          result.plugin = result.plugin || []
 
-        const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
+          const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
 
-        if (Flag.OPENCODE_CONFIG_DIR) {
-          log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
-        }
+          if (Flag.OPENCODE_CONFIG_DIR) {
+            log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
+          }
 
-        const deps: Promise<void>[] = []
+          const deps: Promise<void>[] = []
 
-        for (const dir of unique(directories)) {
-          if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
-            for (const file of ["opencode.jsonc", "opencode.json"]) {
-              log.debug(`loading config from ${path.join(dir, file)}`)
-              result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
-              result.agent ??= {}
-              result.mode ??= {}
-              result.plugin ??= []
+          for (const dir of unique(directories)) {
+            if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
+              for (const file of ["opencode.jsonc", "opencode.json"]) {
+                log.debug(`loading config from ${path.join(dir, file)}`)
+                result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
+                result.agent ??= {}
+                result.mode ??= {}
+                result.plugin ??= []
+              }
             }
-          }
-
-          deps.push(installDependencies(dir))
 
-          result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
-          result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
-          result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
-          result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
-        }
+            const 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)
 
-        if (process.env.OPENCODE_CONFIG_CONTENT) {
-          result = mergeConfigConcatArrays(
-            result,
-            yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
-              dir: ctx.directory,
-              source: "OPENCODE_CONFIG_CONTENT",
-            }),
-          )
-          log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
-        }
+            result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
+            result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
+            result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
+            result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
+          }
 
-        const active = yield* Effect.promise(() => Account.active())
-        if (active?.active_org_id) {
-          yield* Effect.gen(function* () {
-            const [config, token] = yield* Effect.promise(() =>
-              Promise.all([Account.config(active.id, active.active_org_id!), Account.token(active.id)]),
+          if (process.env.OPENCODE_CONFIG_CONTENT) {
+            result = mergeConfigConcatArrays(
+              result,
+              yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
+                dir: ctx.directory,
+                source: "OPENCODE_CONFIG_CONTENT",
+              }),
             )
-            if (token) {
-              process.env["OPENCODE_CONSOLE_TOKEN"] = token
-              Env.set("OPENCODE_CONSOLE_TOKEN", token)
-            }
+            log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
+          }
 
-            if (config) {
-              result = mergeConfigConcatArrays(
-                result,
-                yield* loadConfig(JSON.stringify(config), {
-                  dir: path.dirname(`${active.url}/api/config`),
-                  source: `${active.url}/api/config`,
-                }),
+          const active = Option.getOrUndefined(yield* accountSvc.active().pipe(Effect.orDie))
+          if (active?.active_org_id) {
+            yield* Effect.gen(function* () {
+              const [configOpt, tokenOpt] = yield* Effect.all(
+                [accountSvc.config(active.id, active.active_org_id!), accountSvc.token(active.id)],
+                { concurrency: 2 },
               )
-            }
-          }).pipe(
-            Effect.catchDefect((err) => {
-              log.debug("failed to fetch remote account config", {
-                error: err instanceof Error ? err.message : String(err),
-              })
-              return Effect.void
-            }),
-          )
-        }
+              const token = Option.getOrUndefined(tokenOpt)
+              if (token) {
+                process.env["OPENCODE_CONSOLE_TOKEN"] = token
+                Env.set("OPENCODE_CONSOLE_TOKEN", token)
+              }
 
-        if (existsSync(managedDir)) {
-          for (const file of ["opencode.jsonc", "opencode.json"]) {
-            result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
+              const config = Option.getOrUndefined(configOpt)
+              if (config) {
+                result = mergeConfigConcatArrays(
+                  result,
+                  yield* loadConfig(JSON.stringify(config), {
+                    dir: path.dirname(`${active.url}/api/config`),
+                    source: `${active.url}/api/config`,
+                  }),
+                )
+              }
+            }).pipe(
+              Effect.catch((err) => {
+                log.debug("failed to fetch remote account config", {
+                  error: err instanceof Error ? err.message : String(err),
+                })
+                return Effect.void
+              }),
+            )
           }
-        }
 
-        for (const [name, mode] of Object.entries(result.mode ?? {})) {
-          result.agent = mergeDeep(result.agent ?? {}, {
-            [name]: {
-              ...mode,
-              mode: "primary" as const,
-            },
-          })
-        }
+          if (existsSync(managedDir)) {
+            for (const file of ["opencode.jsonc", "opencode.json"]) {
+              result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
+            }
+          }
 
-        if (Flag.OPENCODE_PERMISSION) {
-          result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
-        }
+          for (const [name, mode] of Object.entries(result.mode ?? {})) {
+            result.agent = mergeDeep(result.agent ?? {}, {
+              [name]: {
+                ...mode,
+                mode: "primary" as const,
+              },
+            })
+          }
+
+          if (Flag.OPENCODE_PERMISSION) {
+            result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
+          }
 
-        if (result.tools) {
-          const perms: Record<string, Config.PermissionAction> = {}
-          for (const [tool, enabled] of Object.entries(result.tools)) {
-            const action: Config.PermissionAction = enabled ? "allow" : "deny"
-            if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
-              perms.edit = action
-              continue
+          if (result.tools) {
+            const perms: Record<string, Config.PermissionAction> = {}
+            for (const [tool, enabled] of Object.entries(result.tools)) {
+              const action: Config.PermissionAction = enabled ? "allow" : "deny"
+              if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+                perms.edit = action
+                continue
+              }
+              perms[tool] = action
             }
-            perms[tool] = action
+            result.permission = mergeDeep(perms, result.permission ?? {})
           }
-          result.permission = mergeDeep(perms, result.permission ?? {})
-        }
 
-        if (!result.username) result.username = os.userInfo().username
+          if (!result.username) result.username = os.userInfo().username
 
-        if (result.autoshare === true && !result.share) {
-          result.share = "auto"
-        }
+          if (result.autoshare === true && !result.share) {
+            result.share = "auto"
+          }
 
-        if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
-          result.compaction = { ...result.compaction, auto: false }
-        }
-        if (Flag.OPENCODE_DISABLE_PRUNE) {
-          result.compaction = { ...result.compaction, prune: false }
-        }
+          if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
+            result.compaction = { ...result.compaction, auto: false }
+          }
+          if (Flag.OPENCODE_DISABLE_PRUNE) {
+            result.compaction = { ...result.compaction, prune: false }
+          }
 
-        result.plugin = deduplicatePlugins(result.plugin ?? [])
+          result.plugin = deduplicatePlugins(result.plugin ?? [])
 
-        return {
-          config: result,
-          directories,
-          deps,
-        }
-      })
+          return {
+            config: result,
+            directories,
+            deps,
+          }
+        })
 
-      const state = yield* InstanceState.make<State>(
-        Effect.fn("Config.state")(function* (ctx) {
-          return yield* loadInstanceState(ctx)
-        }),
-      )
+        const state = yield* InstanceState.make<State>(
+          Effect.fn("Config.state")(function* (ctx) {
+            return yield* loadInstanceState(ctx)
+          }),
+        )
 
-      const get = Effect.fn("Config.get")(function* () {
-        return yield* InstanceState.use(state, (s) => s.config)
-      })
+        const get = Effect.fn("Config.get")(function* () {
+          return yield* InstanceState.use(state, (s) => s.config)
+        })
 
-      const directories = Effect.fn("Config.directories")(function* () {
-        return yield* InstanceState.use(state, (s) => s.directories)
-      })
+        const directories = Effect.fn("Config.directories")(function* () {
+          return yield* InstanceState.use(state, (s) => s.directories)
+        })
 
-      const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
-        yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
-      })
+        const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
+          yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
+        })
 
-      const update = Effect.fn("Config.update")(function* (config: Info) {
-        const file = path.join(Instance.directory, "config.json")
-        const existing = yield* loadFile(file)
-        yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
-        yield* Effect.promise(() => Instance.dispose())
-      })
+        const update = Effect.fn("Config.update")(function* (config: Info) {
+          const file = path.join(Instance.directory, "config.json")
+          const existing = yield* loadFile(file)
+          yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
+          yield* Effect.promise(() => Instance.dispose())
+        })
 
-      const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
-        yield* invalidateGlobal
-        const task = Instance.disposeAll()
-          .catch(() => undefined)
-          .finally(() =>
-            GlobalBus.emit("event", {
-              directory: "global",
-              payload: {
-                type: Event.Disposed.type,
-                properties: {},
-              },
-            }),
-          )
-        if (wait) yield* Effect.promise(() => task)
-        else void task
-      })
+        const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
+          yield* invalidateGlobal
+          const task = Instance.disposeAll()
+            .catch(() => undefined)
+            .finally(() =>
+              GlobalBus.emit("event", {
+                directory: "global",
+                payload: {
+                  type: Event.Disposed.type,
+                  properties: {},
+                },
+              }),
+            )
+          if (wait) yield* Effect.promise(() => task)
+          else void task
+        })
 
-      const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
-        const file = globalConfigFile()
-        const before = (yield* readConfigFile(file)) ?? "{}"
+        const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
+          const file = globalConfigFile()
+          const before = (yield* readConfigFile(file)) ?? "{}"
+
+          let next: Info
+          if (!file.endsWith(".jsonc")) {
+            const existing = parseConfig(before, file)
+            const merged = mergeDeep(existing, config)
+            yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
+            next = merged
+          } else {
+            const updated = patchJsonc(before, config)
+            next = parseConfig(updated, file)
+            yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
+          }
 
-        let next: Info
-        if (!file.endsWith(".jsonc")) {
-          const existing = parseConfig(before, file)
-          const merged = mergeDeep(existing, config)
-          yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
-          next = merged
-        } else {
-          const updated = patchJsonc(before, config)
-          next = parseConfig(updated, file)
-          yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
-        }
+          yield* invalidate()
+          return next
+        })
 
-        yield* invalidate()
-        return next
-      })
+        return Service.of({
+          get,
+          getGlobal,
+          update,
+          updateGlobal,
+          invalidate,
+          directories,
+          waitForDependencies,
+        })
+      }),
+    )
 
-      return Service.of({
-        get,
-        getGlobal,
-        update,
-        updateGlobal,
-        invalidate,
-        directories,
-        waitForDependencies,
-      })
-    }),
+  export const defaultLayer = layer.pipe(
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(Auth.layer),
+    Layer.provide(Account.defaultLayer),
   )
 
-  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
-
   const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function get() {

+ 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
   }
 }

+ 4 - 1
packages/opencode/src/effect/cross-spawn-spawner.ts

@@ -1,5 +1,6 @@
 import type * as Arr from "effect/Array"
-import { NodeSink, NodeStream } from "@effect/platform-node"
+import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node"
+import * as NodePath from "@effect/platform-node/NodePath"
 import * as Deferred from "effect/Deferred"
 import * as Effect from "effect/Effect"
 import * as Exit from "effect/Exit"
@@ -474,3 +475,5 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
   ChildProcessSpawner,
   make,
 )
+
+export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))

+ 6 - 2
packages/opencode/src/file/watcher.ts

@@ -70,6 +70,8 @@ export namespace FileWatcher {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const config = yield* Config.Service
+
       const state = yield* InstanceState.make(
         Effect.fn("FileWatcher.state")(
           function* () {
@@ -117,7 +119,7 @@ export namespace FileWatcher {
               )
             }
 
-            const cfg = yield* Effect.promise(() => Config.get())
+            const cfg = yield* config.get()
             const cfgIgnores = cfg.watcher?.ignore ?? []
 
             if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
@@ -159,7 +161,9 @@ export namespace FileWatcher {
     }),
   )
 
-  const { runPromise } = makeRuntime(Service, layer)
+  export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export function init() {
     return runPromise((svc) => svc.init())

+ 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

+ 6 - 2
packages/opencode/src/format/index.ts

@@ -35,12 +35,14 @@ export namespace Format {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const config = yield* Config.Service
+
       const state = yield* InstanceState.make(
         Effect.fn("Format.state")(function* (_ctx) {
           const enabled: Record<string, string[] | false> = {}
           const formatters: Record<string, Formatter.Info> = {}
 
-          const cfg = yield* Effect.promise(() => Config.get())
+          const cfg = yield* config.get()
 
           if (cfg.formatter !== false) {
             for (const item of Object.values(Formatter)) {
@@ -177,7 +179,9 @@ export namespace Format {
     }),
   )
 
-  const { runPromise } = makeRuntime(Service, layer)
+  export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function init() {
     return runPromise((s) => s.init())

+ 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 {

+ 1 - 4
packages/opencode/src/installation/index.ts

@@ -1,4 +1,3 @@
-import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -341,9 +340,7 @@ export namespace Installation {
 
   export const defaultLayer = layer.pipe(
     Layer.provide(FetchHttpClient.layer),
-    Layer.provide(CrossSpawnSpawner.layer),
-    Layer.provide(NodeFileSystem.layer),
-    Layer.provide(NodePath.layer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
   )
 
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 6 - 2
packages/opencode/src/lsp/index.ts

@@ -161,9 +161,11 @@ export namespace LSP {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const config = yield* Config.Service
+
       const state = yield* InstanceState.make<State>(
         Effect.fn("LSP.state")(function* () {
-          const cfg = yield* Effect.promise(() => Config.get())
+          const cfg = yield* config.get()
 
           const servers: Record<string, LSPServer.Info> = {}
 
@@ -504,7 +506,9 @@ export namespace LSP {
     }),
   )
 
-  const { runPromise } = makeRuntime(Service, layer)
+  export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export const init = async () => runPromise((svc) => svc.init())
 

+ 10 - 12
packages/opencode/src/mcp/index.ts

@@ -29,8 +29,6 @@ import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
-import { NodeFileSystem } from "@effect/platform-node"
-import * as NodePath from "@effect/platform-node/NodePath"
 
 export namespace MCP {
   const log = Log.create({ service: "mcp" })
@@ -437,6 +435,7 @@ export namespace MCP {
         log.info("create() successfully created client", { key, toolCount: listed.length })
         return { mcpClient, status, defs: listed } satisfies CreateResult
       })
+      const cfgSvc = yield* Config.Service
 
       const descendants = Effect.fnUntraced(
         function* (pid: number) {
@@ -478,11 +477,9 @@ export namespace MCP {
         })
       }
 
-      const getConfig = () => Effect.promise(() => Config.get())
-
       const cache = yield* InstanceState.make<State>(
         Effect.fn("MCP.state")(function* () {
-          const cfg = yield* getConfig()
+          const cfg = yield* cfgSvc.get()
           const config = cfg.mcp ?? {}
           const s: State = {
             status: {},
@@ -553,7 +550,8 @@ export namespace MCP {
 
       const status = Effect.fn("MCP.status")(function* () {
         const s = yield* InstanceState.get(cache)
-        const cfg = yield* getConfig()
+
+        const cfg = yield* cfgSvc.get()
         const config = cfg.mcp ?? {}
         const result: Record<string, Status> = {}
 
@@ -613,7 +611,8 @@ export namespace MCP {
       const tools = Effect.fn("MCP.tools")(function* () {
         const result: Record<string, Tool> = {}
         const s = yield* InstanceState.get(cache)
-        const cfg = yield* getConfig()
+
+        const cfg = yield* cfgSvc.get()
         const config = cfg.mcp ?? {}
         const defaultTimeout = cfg.experimental?.mcp_timeout
 
@@ -705,7 +704,7 @@ export namespace MCP {
       })
 
       const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
-        const cfg = yield* getConfig()
+        const cfg = yield* cfgSvc.get()
         const mcpConfig = cfg.mcp?.[mcpName]
         if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
         return mcpConfig
@@ -876,13 +875,12 @@ export namespace MCP {
 
   // --- Per-service runtime ---
 
-  const defaultLayer = layer.pipe(
+  export const defaultLayer = layer.pipe(
     Layer.provide(McpAuth.layer),
     Layer.provide(Bus.layer),
-    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(Config.defaultLayer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(NodeFileSystem.layer),
-    Layer.provide(NodePath.layer),
   )
 
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 146 - 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 { Npm } from "../npm"
 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,
@@ -80,8 +203,7 @@ export namespace Plugin {
               get serverUrl(): URL {
                 return Server.url ?? new URL("http://localhost:4096")
               },
-              // @ts-expect-error
-              $: typeof Bun === "undefined" ? undefined : Bun.$,
+              $: Bun.$,
             }
 
             for (const plugin of INTERNAL_PLUGINS) {
@@ -92,48 +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://")) {
-                plugin = await Npm.add(plugin).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", { plugin, error: detail })
-                  Bus.publish(Session.Event.Error, {
-                    error: new NamedError.Unknown({
-                      message: `Failed to install plugin ${plugin}: ${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
@@ -192,7 +293,7 @@ export namespace Plugin {
     }),
   )
 
-  const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
+  export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
   const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function trigger<

+ 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
+}

+ 9 - 10
packages/opencode/src/project/project.ts

@@ -111,7 +111,7 @@ export namespace Project {
   > = Layer.effect(
     Service,
     Effect.gen(function* () {
-      const fsys = yield* AppFileSystem.Service
+      const fs = yield* AppFileSystem.Service
       const pathSvc = yield* Path.Path
       const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
 
@@ -155,7 +155,7 @@ export namespace Project {
       const scope = yield* Scope.Scope
 
       const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
-        return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe(
+        return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
           Effect.map((x) => x.trim()),
           Effect.map(ProjectID.make),
           Effect.catch(() => Effect.succeed(undefined)),
@@ -169,7 +169,7 @@ export namespace Project {
         type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
 
         const data: DiscoveryResult = yield* Effect.gen(function* () {
-          const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
+          const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
           const dotgit = dotgitMatches[0]
 
           if (!dotgit) {
@@ -222,7 +222,7 @@ export namespace Project {
 
             id = roots[0] ? ProjectID.make(roots[0]) : undefined
             if (id) {
-              yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
+              yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
             }
           }
 
@@ -270,7 +270,7 @@ export namespace Project {
         result.sandboxes = yield* Effect.forEach(
           result.sandboxes,
           (s) =>
-            fsys.exists(s).pipe(
+            fs.exists(s).pipe(
               Effect.orDie,
               Effect.map((exists) => (exists ? s : undefined)),
             ),
@@ -329,7 +329,7 @@ export namespace Project {
         if (input.icon?.override) return
         if (input.icon?.url) return
 
-        const matches = yield* fsys
+        const matches = yield* fs
           .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
             cwd: input.worktree,
             absolute: true,
@@ -339,7 +339,7 @@ export namespace Project {
         const shortest = matches.sort((a, b) => a.length - b.length)[0]
         if (!shortest) return
 
-        const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
+        const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
         const base64 = Buffer.from(buffer).toString("base64")
         const mime = AppFileSystem.mimeType(shortest)
         const url = `data:${mime};base64,${base64}`
@@ -400,7 +400,7 @@ export namespace Project {
         return yield* Effect.forEach(
           data.sandboxes,
           (dir) =>
-            fsys.isDir(dir).pipe(
+            fs.isDir(dir).pipe(
               Effect.orDie,
               Effect.map((ok) => (ok ? dir : undefined)),
             ),
@@ -457,9 +457,8 @@ export namespace Project {
   )
 
   export const defaultLayer = layer.pipe(
-    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(NodeFileSystem.layer),
     Layer.provide(NodePath.layer),
   )
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 26 - 13
packages/opencode/src/project/vcs.ts

@@ -1,12 +1,12 @@
 import { Effect, Layer, ServiceMap, Stream } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
 import { FileWatcher } from "@/file/watcher"
 import { Log } from "@/util/log"
-import { git } from "@/util/git"
-import { Instance } from "./instance"
 import z from "zod"
 
 export namespace Vcs {
@@ -41,10 +41,25 @@ export namespace Vcs {
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
 
-  export const layer: Layer.Layer<Service, never, Bus.Service> = Layer.effect(
+  export const layer: Layer.Layer<Service, never, Bus.Service | ChildProcessSpawner.ChildProcessSpawner> = Layer.effect(
     Service,
     Effect.gen(function* () {
       const bus = yield* Bus.Service
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+
+      const git = Effect.fnUntraced(
+        function* (args: string[], opts: { cwd: string }) {
+          const handle = yield* spawner.spawn(
+            ChildProcess.make("git", args, { cwd: opts.cwd, extendEnv: true, stdin: "ignore" }),
+          )
+          const text = yield* Stream.mkString(Stream.decodeText(handle.stdout))
+          const code = yield* handle.exitCode
+          return { code, text }
+        },
+        Effect.scoped,
+        Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), text: "" })),
+      )
+
       const state = yield* InstanceState.make<State>(
         Effect.fn("Vcs.state")((ctx) =>
           Effect.gen(function* () {
@@ -52,17 +67,15 @@ export namespace Vcs {
               return { current: undefined }
             }
 
-            const get = async () => {
-              const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
-                cwd: ctx.worktree,
-              })
-              if (result.exitCode !== 0) return undefined
-              const text = result.text().trim()
+            const getBranch = Effect.fnUntraced(function* () {
+              const result = yield* git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: ctx.worktree })
+              if (result.code !== 0) return undefined
+              const text = result.text.trim()
               return text || undefined
-            }
+            })
 
             const value = {
-              current: yield* Effect.promise(() => get()),
+              current: yield* getBranch(),
             }
             log.info("initialized", { branch: value.current })
 
@@ -70,7 +83,7 @@ export namespace Vcs {
               Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
               Stream.runForEach(() =>
                 Effect.gen(function* () {
-                  const next = yield* Effect.promise(() => get())
+                  const next = yield* getBranch()
                   if (next !== value.current) {
                     log.info("branch changed", { from: value.current, to: next })
                     value.current = next
@@ -97,7 +110,7 @@ export namespace Vcs {
     }),
   )
 
-  export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
+  export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(CrossSpawnSpawner.defaultLayer))
 
   const { runPromise } = makeRuntime(Service, defaultLayer)
 

+ 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>(),
             }
           }),
         ),

+ 1 - 1
packages/opencode/src/pty/index.ts

@@ -273,7 +273,7 @@ export namespace Pty {
         if (input.size) {
           session.process.resize(input.size.cols, input.size.rows)
         }
-        yield* Effect.promise(() => Bus.publish(Event.Updated, { info: session.info }))
+        void Bus.publish(Event.Updated, { info: session.info })
         return session.info
       })
 

+ 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)

+ 96 - 50
packages/opencode/src/skill/index.ts

@@ -63,16 +63,23 @@ export namespace Skill {
     readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
   }
 
-  const add = async (state: State, match: string) => {
-    const md = await ConfigMarkdown.parse(match).catch(async (err) => {
-      const message = ConfigMarkdown.FrontmatterError.isInstance(err)
-        ? err.data.message
-        : `Failed to parse skill ${match}`
-      const { Session } = await import("@/session")
-      Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
-      log.error("failed to load skill", { skill: match, err })
-      return undefined
-    })
+  const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) {
+    const md = yield* Effect.tryPromise({
+      try: () => ConfigMarkdown.parse(match),
+      catch: (err) => err,
+    }).pipe(
+      Effect.catch(
+        Effect.fnUntraced(function* (err) {
+          const message = ConfigMarkdown.FrontmatterError.isInstance(err)
+            ? err.data.message
+            : `Failed to parse skill ${match}`
+          const { Session } = yield* Effect.promise(() => import("@/session"))
+          yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+          log.error("failed to load skill", { skill: match, err })
+          return undefined
+        }),
+      ),
+    )
 
     if (!md) return
 
@@ -94,80 +101,115 @@ export namespace Skill {
       location: match,
       content: md.content,
     }
-  }
+  })
 
-  const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
-    return Glob.scan(pattern, {
-      cwd: root,
-      absolute: true,
-      include: "file",
-      symlink: true,
-      dot: opts?.dot,
-    })
-      .then((matches) => Promise.all(matches.map((match) => add(state, match))))
-      .catch((error) => {
-        if (!opts?.scope) throw error
+  const scan = Effect.fnUntraced(function* (
+    state: State,
+    bus: Bus.Interface,
+    root: string,
+    pattern: string,
+    opts?: { dot?: boolean; scope?: string },
+  ) {
+    const matches = yield* Effect.tryPromise({
+      try: () =>
+        Glob.scan(pattern, {
+          cwd: root,
+          absolute: true,
+          include: "file",
+          symlink: true,
+          dot: opts?.dot,
+        }),
+      catch: (error) => error,
+    }).pipe(
+      Effect.catch((error) => {
+        if (!opts?.scope) return Effect.die(error)
         log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
-      })
-  }
+        return Effect.succeed([] as string[])
+      }),
+    )
+
+    yield* Effect.forEach(matches, (match) => add(state, match, bus), {
+      concurrency: "unbounded",
+      discard: true,
+    })
+  })
 
-  async function loadSkills(state: State, discovery: Discovery.Interface, directory: string, worktree: string) {
+  const loadSkills = Effect.fnUntraced(function* (
+    state: State,
+    config: Config.Interface,
+    discovery: Discovery.Interface,
+    bus: Bus.Interface,
+    directory: string,
+    worktree: string,
+  ) {
     if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
       for (const dir of EXTERNAL_DIRS) {
         const root = path.join(Global.Path.home, dir)
-        if (!(await Filesystem.isDir(root))) continue
-        await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
+        const isDir = yield* Effect.promise(() => Filesystem.isDir(root))
+        if (!isDir) continue
+        yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
       }
 
-      for await (const root of Filesystem.up({
-        targets: EXTERNAL_DIRS,
-        start: directory,
-        stop: worktree,
-      })) {
-        await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
+      const upDirs = yield* Effect.promise(async () => {
+        const dirs: string[] = []
+        for await (const root of Filesystem.up({
+          targets: EXTERNAL_DIRS,
+          start: directory,
+          stop: worktree,
+        })) {
+          dirs.push(root)
+        }
+        return dirs
+      })
+
+      for (const root of upDirs) {
+        yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
       }
     }
 
-    for (const dir of await Config.directories()) {
-      await scan(state, dir, OPENCODE_SKILL_PATTERN)
+    const configDirs = yield* config.directories()
+    for (const dir of configDirs) {
+      yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN)
     }
 
-    const cfg = await Config.get()
+    const cfg = yield* config.get()
     for (const item of cfg.skills?.paths ?? []) {
       const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
       const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
-      if (!(await Filesystem.isDir(dir))) {
+      const isDir = yield* Effect.promise(() => Filesystem.isDir(dir))
+      if (!isDir) {
         log.warn("skill path not found", { path: dir })
         continue
       }
 
-      await scan(state, dir, SKILL_PATTERN)
+      yield* scan(state, bus, dir, SKILL_PATTERN)
     }
 
     for (const url of cfg.skills?.urls ?? []) {
-      for (const dir of await Effect.runPromise(discovery.pull(url))) {
+      const pulledDirs = yield* discovery.pull(url)
+      for (const dir of pulledDirs) {
         state.dirs.add(dir)
-        await scan(state, dir, SKILL_PATTERN)
+        yield* scan(state, bus, dir, SKILL_PATTERN)
       }
     }
 
     log.info("init", { count: Object.keys(state.skills).length })
-  }
+  })
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
 
-  export const layer: Layer.Layer<Service, never, Discovery.Service> = Layer.effect(
+  export const layer: Layer.Layer<Service, never, Discovery.Service | Config.Service | Bus.Service> = Layer.effect(
     Service,
     Effect.gen(function* () {
       const discovery = yield* Discovery.Service
+      const config = yield* Config.Service
+      const bus = yield* Bus.Service
       const state = yield* InstanceState.make(
-        Effect.fn("Skill.state")((ctx) =>
-          Effect.gen(function* () {
-            const s: State = { skills: {}, dirs: new Set() }
-            yield* Effect.promise(() => loadSkills(s, discovery, ctx.directory, ctx.worktree))
-            return s
-          }),
-        ),
+        Effect.fn("Skill.state")(function* (ctx) {
+          const s: State = { skills: {}, dirs: new Set() }
+          yield* loadSkills(s, config, discovery, bus, ctx.directory, ctx.worktree)
+          return s
+        }),
       )
 
       const get = Effect.fn("Skill.get")(function* (name: string) {
@@ -196,7 +238,11 @@ export namespace Skill {
     }),
   )
 
-  export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Discovery.defaultLayer))
+  export const defaultLayer: Layer.Layer<Service> = layer.pipe(
+    Layer.provide(Discovery.defaultLayer),
+    Layer.provide(Config.defaultLayer),
+    Layer.provide(Bus.layer),
+  )
 
   export function fmt(list: Info[], opts: { verbose: boolean }) {
     if (list.length === 0) return "No skills are currently available."

+ 366 - 372
packages/opencode/src/snapshot/index.ts

@@ -60,403 +60,397 @@ export namespace Snapshot {
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
 
-  export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner> =
-    Layer.effect(
-      Service,
-      Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
-        const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
-        const locks = new Map<string, Semaphore.Semaphore>()
-
-        const lock = (key: string) => {
-          const hit = locks.get(key)
-          if (hit) return hit
-
-          const next = Semaphore.makeUnsafe(1)
-          locks.set(key, next)
-          return next
-        }
-
-        const state = yield* InstanceState.make<State>(
-          Effect.fn("Snapshot.state")(function* (ctx) {
-            const state = {
-              directory: ctx.directory,
-              worktree: ctx.worktree,
-              gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
-              vcs: ctx.project.vcs,
-            }
-
-            const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
-
-            const git = Effect.fnUntraced(
-              function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
-                const proc = ChildProcess.make("git", cmd, {
-                  cwd: opts?.cwd,
-                  env: opts?.env,
-                  extendEnv: true,
-                })
-                const handle = yield* spawner.spawn(proc)
-                const [text, stderr] = yield* Effect.all(
-                  [
-                    Stream.mkString(Stream.decodeText(handle.stdout)),
-                    Stream.mkString(Stream.decodeText(handle.stderr)),
-                  ],
-                  { concurrency: 2 },
-                )
-                const code = yield* handle.exitCode
-                return { code, text, stderr } satisfies GitResult
-              },
-              Effect.scoped,
-              Effect.catch((err) =>
-                Effect.succeed({
-                  code: ChildProcessSpawner.ExitCode(1),
-                  text: "",
-                  stderr: String(err),
-                }),
-              ),
-            )
-
-            const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
-            const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
-            const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
-            const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
-
-            const enabled = Effect.fnUntraced(function* () {
-              if (state.vcs !== "git") return false
-              return (yield* Effect.promise(() => Config.get())).snapshot !== false
-            })
-
-            const excludes = Effect.fnUntraced(function* () {
-              const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
-                cwd: state.worktree,
+  export const layer: Layer.Layer<
+    Service,
+    never,
+    AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service
+  > = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+      const config = yield* Config.Service
+      const locks = new Map<string, Semaphore.Semaphore>()
+
+      const lock = (key: string) => {
+        const hit = locks.get(key)
+        if (hit) return hit
+
+        const next = Semaphore.makeUnsafe(1)
+        locks.set(key, next)
+        return next
+      }
+
+      const state = yield* InstanceState.make<State>(
+        Effect.fn("Snapshot.state")(function* (ctx) {
+          const state = {
+            directory: ctx.directory,
+            worktree: ctx.worktree,
+            gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
+            vcs: ctx.project.vcs,
+          }
+
+          const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
+
+          const git = Effect.fnUntraced(
+            function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
+              const proc = ChildProcess.make("git", cmd, {
+                cwd: opts?.cwd,
+                env: opts?.env,
+                extendEnv: true,
               })
-              const file = result.text.trim()
-              if (!file) return
-              if (!(yield* exists(file))) return
-              return file
-            })
-
-            const sync = Effect.fnUntraced(function* (list: string[] = []) {
-              const file = yield* excludes()
-              const target = path.join(state.gitdir, "info", "exclude")
-              const text = [
-                file ? (yield* read(file)).trimEnd() : "",
-                ...list.map((item) => `/${item.replaceAll("\\", "/")}`),
-              ]
-                .filter(Boolean)
-                .join("\n")
-              yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
-              yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
-            })
-
-            const add = Effect.fnUntraced(function* () {
-              yield* sync()
-              const [diff, other] = yield* Effect.all(
-                [
-                  git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
-                    cwd: state.directory,
-                  }),
-                  git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
-                    cwd: state.directory,
-                  }),
-                ],
+              const handle = yield* spawner.spawn(proc)
+              const [text, stderr] = yield* Effect.all(
+                [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
                 { concurrency: 2 },
               )
-              if (diff.code !== 0 || other.code !== 0) {
-                log.warn("failed to list snapshot files", {
-                  diffCode: diff.code,
-                  diffStderr: diff.stderr,
-                  otherCode: other.code,
-                  otherStderr: other.stderr,
-                })
-                return
-              }
-
-              const tracked = diff.text.split("\0").filter(Boolean)
-              const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
-              if (!all.length) return
-
-              const large = (yield* Effect.all(
-                all.map((item) =>
-                  fs
-                    .stat(path.join(state.directory, item))
-                    .pipe(Effect.catch(() => Effect.void))
-                    .pipe(
-                      Effect.map((stat) => {
-                        if (!stat || stat.type !== "File") return
-                        const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
-                        return size > limit ? item : undefined
-                      }),
-                    ),
-                ),
-                { concurrency: 8 },
-              )).filter((item): item is string => Boolean(item))
-              yield* sync(large)
-              const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
-              if (result.code !== 0) {
-                log.warn("failed to add snapshot files", {
-                  exitCode: result.code,
-                  stderr: result.stderr,
-                })
-              }
-            })
-
-            const cleanup = Effect.fnUntraced(function* () {
-              return yield* locked(
-                Effect.gen(function* () {
-                  if (!(yield* enabled())) return
-                  if (!(yield* exists(state.gitdir))) return
-                  const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
-                  if (result.code !== 0) {
-                    log.warn("cleanup failed", {
-                      exitCode: result.code,
-                      stderr: result.stderr,
-                    })
-                    return
-                  }
-                  log.info("cleanup", { prune })
-                }),
-              )
+              const code = yield* handle.exitCode
+              return { code, text, stderr } satisfies GitResult
+            },
+            Effect.scoped,
+            Effect.catch((err) =>
+              Effect.succeed({
+                code: ChildProcessSpawner.ExitCode(1),
+                text: "",
+                stderr: String(err),
+              }),
+            ),
+          )
+
+          const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
+          const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
+          const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
+          const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
+
+          const enabled = Effect.fnUntraced(function* () {
+            if (state.vcs !== "git") return false
+            return (yield* config.get()).snapshot !== false
+          })
+
+          const excludes = Effect.fnUntraced(function* () {
+            const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
+              cwd: state.worktree,
             })
-
-            const track = Effect.fnUntraced(function* () {
-              return yield* locked(
-                Effect.gen(function* () {
-                  if (!(yield* enabled())) return
-                  const existed = yield* exists(state.gitdir)
-                  yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
-                  if (!existed) {
-                    yield* git(["init"], {
-                      env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
-                    })
-                    yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
-                    yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
-                    yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
-                    yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
-                    log.info("initialized")
-                  }
-                  yield* add()
-                  const result = yield* git(args(["write-tree"]), { cwd: state.directory })
-                  const hash = result.text.trim()
-                  log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
-                  return hash
+            const file = result.text.trim()
+            if (!file) return
+            if (!(yield* exists(file))) return
+            return file
+          })
+
+          const sync = Effect.fnUntraced(function* (list: string[] = []) {
+            const file = yield* excludes()
+            const target = path.join(state.gitdir, "info", "exclude")
+            const text = [
+              file ? (yield* read(file)).trimEnd() : "",
+              ...list.map((item) => `/${item.replaceAll("\\", "/")}`),
+            ]
+              .filter(Boolean)
+              .join("\n")
+            yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
+            yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
+          })
+
+          const add = Effect.fnUntraced(function* () {
+            yield* sync()
+            const [diff, other] = yield* Effect.all(
+              [
+                git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
+                  cwd: state.directory,
                 }),
-              )
-            })
-
-            const patch = Effect.fnUntraced(function* (hash: string) {
-              return yield* locked(
-                Effect.gen(function* () {
-                  yield* add()
-                  const result = yield* git(
-                    [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
-                    {
-                      cwd: state.directory,
-                    },
-                  )
-                  if (result.code !== 0) {
-                    log.warn("failed to get diff", { hash, exitCode: result.code })
-                    return { hash, files: [] }
-                  }
-                  return {
-                    hash,
-                    files: result.text
-                      .trim()
-                      .split("\n")
-                      .map((x) => x.trim())
-                      .filter(Boolean)
-                      .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
-                  }
+                git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
+                  cwd: state.directory,
                 }),
-              )
-            })
+              ],
+              { concurrency: 2 },
+            )
+            if (diff.code !== 0 || other.code !== 0) {
+              log.warn("failed to list snapshot files", {
+                diffCode: diff.code,
+                diffStderr: diff.stderr,
+                otherCode: other.code,
+                otherStderr: other.stderr,
+              })
+              return
+            }
 
-            const restore = Effect.fnUntraced(function* (snapshot: string) {
-              return yield* locked(
-                Effect.gen(function* () {
-                  log.info("restore", { commit: snapshot })
-                  const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
-                  if (result.code === 0) {
-                    const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
-                      cwd: state.worktree,
-                    })
-                    if (checkout.code === 0) return
-                    log.error("failed to restore snapshot", {
-                      snapshot,
-                      exitCode: checkout.code,
-                      stderr: checkout.stderr,
-                    })
-                    return
-                  }
-                  log.error("failed to restore snapshot", {
-                    snapshot,
+            const tracked = diff.text.split("\0").filter(Boolean)
+            const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
+            if (!all.length) return
+
+            const large = (yield* Effect.all(
+              all.map((item) =>
+                fs
+                  .stat(path.join(state.directory, item))
+                  .pipe(Effect.catch(() => Effect.void))
+                  .pipe(
+                    Effect.map((stat) => {
+                      if (!stat || stat.type !== "File") return
+                      const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
+                      return size > limit ? item : undefined
+                    }),
+                  ),
+              ),
+              { concurrency: 8 },
+            )).filter((item): item is string => Boolean(item))
+            yield* sync(large)
+            const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
+            if (result.code !== 0) {
+              log.warn("failed to add snapshot files", {
+                exitCode: result.code,
+                stderr: result.stderr,
+              })
+            }
+          })
+
+          const cleanup = Effect.fnUntraced(function* () {
+            return yield* locked(
+              Effect.gen(function* () {
+                if (!(yield* enabled())) return
+                if (!(yield* exists(state.gitdir))) return
+                const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
+                if (result.code !== 0) {
+                  log.warn("cleanup failed", {
                     exitCode: result.code,
                     stderr: result.stderr,
                   })
-                }),
-              )
-            })
-
-            const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
-              return yield* locked(
-                Effect.gen(function* () {
-                  const seen = new Set<string>()
-                  for (const item of patches) {
-                    for (const file of item.files) {
-                      if (seen.has(file)) continue
-                      seen.add(file)
-                      log.info("reverting", { file, hash: item.hash })
-                      const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
+                  return
+                }
+                log.info("cleanup", { prune })
+              }),
+            )
+          })
+
+          const track = Effect.fnUntraced(function* () {
+            return yield* locked(
+              Effect.gen(function* () {
+                if (!(yield* enabled())) return
+                const existed = yield* exists(state.gitdir)
+                yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
+                if (!existed) {
+                  yield* git(["init"], {
+                    env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
+                  })
+                  yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
+                  yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
+                  yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
+                  yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
+                  log.info("initialized")
+                }
+                yield* add()
+                const result = yield* git(args(["write-tree"]), { cwd: state.directory })
+                const hash = result.text.trim()
+                log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
+                return hash
+              }),
+            )
+          })
+
+          const patch = Effect.fnUntraced(function* (hash: string) {
+            return yield* locked(
+              Effect.gen(function* () {
+                yield* add()
+                const result = yield* git(
+                  [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
+                  {
+                    cwd: state.directory,
+                  },
+                )
+                if (result.code !== 0) {
+                  log.warn("failed to get diff", { hash, exitCode: result.code })
+                  return { hash, files: [] }
+                }
+                return {
+                  hash,
+                  files: result.text
+                    .trim()
+                    .split("\n")
+                    .map((x) => x.trim())
+                    .filter(Boolean)
+                    .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
+                }
+              }),
+            )
+          })
+
+          const restore = Effect.fnUntraced(function* (snapshot: string) {
+            return yield* locked(
+              Effect.gen(function* () {
+                log.info("restore", { commit: snapshot })
+                const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
+                if (result.code === 0) {
+                  const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
+                    cwd: state.worktree,
+                  })
+                  if (checkout.code === 0) return
+                  log.error("failed to restore snapshot", {
+                    snapshot,
+                    exitCode: checkout.code,
+                    stderr: checkout.stderr,
+                  })
+                  return
+                }
+                log.error("failed to restore snapshot", {
+                  snapshot,
+                  exitCode: result.code,
+                  stderr: result.stderr,
+                })
+              }),
+            )
+          })
+
+          const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
+            return yield* locked(
+              Effect.gen(function* () {
+                const seen = new Set<string>()
+                for (const item of patches) {
+                  for (const file of item.files) {
+                    if (seen.has(file)) continue
+                    seen.add(file)
+                    log.info("reverting", { file, hash: item.hash })
+                    const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
+                      cwd: state.worktree,
+                    })
+                    if (result.code !== 0) {
+                      const rel = path.relative(state.worktree, file)
+                      const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
                         cwd: state.worktree,
                       })
-                      if (result.code !== 0) {
-                        const rel = path.relative(state.worktree, file)
-                        const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
-                          cwd: state.worktree,
-                        })
-                        if (tree.code === 0 && tree.text.trim()) {
-                          log.info("file existed in snapshot but checkout failed, keeping", { file })
-                        } else {
-                          log.info("file did not exist in snapshot, deleting", { file })
-                          yield* remove(file)
-                        }
+                      if (tree.code === 0 && tree.text.trim()) {
+                        log.info("file existed in snapshot but checkout failed, keeping", { file })
+                      } else {
+                        log.info("file did not exist in snapshot, deleting", { file })
+                        yield* remove(file)
                       }
                     }
                   }
-                }),
-              )
-            })
+                }
+              }),
+            )
+          })
+
+          const diff = Effect.fnUntraced(function* (hash: string) {
+            return yield* locked(
+              Effect.gen(function* () {
+                yield* add()
+                const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
+                  cwd: state.worktree,
+                })
+                if (result.code !== 0) {
+                  log.warn("failed to get diff", {
+                    hash,
+                    exitCode: result.code,
+                    stderr: result.stderr,
+                  })
+                  return ""
+                }
+                return result.text.trim()
+              }),
+            )
+          })
 
-            const diff = Effect.fnUntraced(function* (hash: string) {
-              return yield* locked(
-                Effect.gen(function* () {
-                  yield* add()
-                  const result = yield* git(
-                    [...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])],
-                    {
-                      cwd: state.worktree,
-                    },
-                  )
-                  if (result.code !== 0) {
-                    log.warn("failed to get diff", {
-                      hash,
-                      exitCode: result.code,
-                      stderr: result.stderr,
-                    })
-                    return ""
-                  }
-                  return result.text.trim()
-                }),
-              )
-            })
+          const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
+            return yield* locked(
+              Effect.gen(function* () {
+                const result: Snapshot.FileDiff[] = []
+                const status = new Map<string, "added" | "deleted" | "modified">()
 
-            const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
-              return yield* locked(
-                Effect.gen(function* () {
-                  const result: Snapshot.FileDiff[] = []
-                  const status = new Map<string, "added" | "deleted" | "modified">()
-
-                  const statuses = yield* git(
-                    [
-                      ...quote,
-                      ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
-                    ],
-                    { cwd: state.directory },
-                  )
-
-                  for (const line of statuses.text.trim().split("\n")) {
-                    if (!line) continue
-                    const [code, file] = line.split("\t")
-                    if (!code || !file) continue
-                    status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
-                  }
+                const statuses = yield* git(
+                  [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
+                  { cwd: state.directory },
+                )
 
-                  const numstat = yield* git(
-                    [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
-                    {
-                      cwd: state.directory,
-                    },
-                  )
-
-                  for (const line of numstat.text.trim().split("\n")) {
-                    if (!line) continue
-                    const [adds, dels, file] = line.split("\t")
-                    if (!file) continue
-                    const binary = adds === "-" && dels === "-"
-                    const [before, after] = binary
-                      ? ["", ""]
-                      : yield* Effect.all(
-                          [
-                            git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
-                            git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
-                          ],
-                          { concurrency: 2 },
-                        )
-                    const additions = binary ? 0 : parseInt(adds)
-                    const deletions = binary ? 0 : parseInt(dels)
-                    result.push({
-                      file,
-                      before,
-                      after,
-                      additions: Number.isFinite(additions) ? additions : 0,
-                      deletions: Number.isFinite(deletions) ? deletions : 0,
-                      status: status.get(file) ?? "modified",
-                    })
-                  }
+                for (const line of statuses.text.trim().split("\n")) {
+                  if (!line) continue
+                  const [code, file] = line.split("\t")
+                  if (!code || !file) continue
+                  status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
+                }
 
-                  return result
-                }),
-              )
-            })
+                const numstat = yield* git(
+                  [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
+                  {
+                    cwd: state.directory,
+                  },
+                )
+
+                for (const line of numstat.text.trim().split("\n")) {
+                  if (!line) continue
+                  const [adds, dels, file] = line.split("\t")
+                  if (!file) continue
+                  const binary = adds === "-" && dels === "-"
+                  const [before, after] = binary
+                    ? ["", ""]
+                    : yield* Effect.all(
+                        [
+                          git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
+                          git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
+                        ],
+                        { concurrency: 2 },
+                      )
+                  const additions = binary ? 0 : parseInt(adds)
+                  const deletions = binary ? 0 : parseInt(dels)
+                  result.push({
+                    file,
+                    before,
+                    after,
+                    additions: Number.isFinite(additions) ? additions : 0,
+                    deletions: Number.isFinite(deletions) ? deletions : 0,
+                    status: status.get(file) ?? "modified",
+                  })
+                }
 
-            yield* cleanup().pipe(
-              Effect.catchCause((cause) => {
-                log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
-                return Effect.void
+                return result
               }),
-              Effect.repeat(Schedule.spaced(Duration.hours(1))),
-              Effect.delay(Duration.minutes(1)),
-              Effect.forkScoped,
             )
-
-            return { cleanup, track, patch, restore, revert, diff, diffFull }
-          }),
-        )
-
-        return Service.of({
-          init: Effect.fn("Snapshot.init")(function* () {
-            yield* InstanceState.get(state)
-          }),
-          cleanup: Effect.fn("Snapshot.cleanup")(function* () {
-            return yield* InstanceState.useEffect(state, (s) => s.cleanup())
-          }),
-          track: Effect.fn("Snapshot.track")(function* () {
-            return yield* InstanceState.useEffect(state, (s) => s.track())
-          }),
-          patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
-            return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
-          }),
-          restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
-            return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
-          }),
-          revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
-            return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
-          }),
-          diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
-            return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
-          }),
-          diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
-            return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
-          }),
-        })
-      }),
-    )
+          })
+
+          yield* cleanup().pipe(
+            Effect.catchCause((cause) => {
+              log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
+              return Effect.void
+            }),
+            Effect.repeat(Schedule.spaced(Duration.hours(1))),
+            Effect.delay(Duration.minutes(1)),
+            Effect.forkScoped,
+          )
+
+          return { cleanup, track, patch, restore, revert, diff, diffFull }
+        }),
+      )
+
+      return Service.of({
+        init: Effect.fn("Snapshot.init")(function* () {
+          yield* InstanceState.get(state)
+        }),
+        cleanup: Effect.fn("Snapshot.cleanup")(function* () {
+          return yield* InstanceState.useEffect(state, (s) => s.cleanup())
+        }),
+        track: Effect.fn("Snapshot.track")(function* () {
+          return yield* InstanceState.useEffect(state, (s) => s.track())
+        }),
+        patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
+          return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
+        }),
+        restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
+          return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
+        }),
+        revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
+          return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
+        }),
+        diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
+          return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
+        }),
+        diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
+          return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
+        }),
+      })
+    }),
+  )
 
   export const defaultLayer = layer.pipe(
-    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(NodeFileSystem.layer), // needed by CrossSpawnSpawner
-    Layer.provide(NodePath.layer),
+    Layer.provide(Config.defaultLayer),
   )
 
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 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(),

+ 61 - 58
packages/opencode/src/tool/registry.ts

@@ -54,6 +54,9 @@ export namespace ToolRegistry {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const config = yield* Config.Service
+      const plugin = yield* Plugin.Service
+
       const cache = yield* InstanceState.make<State>(
         Effect.fn("ToolRegistry.state")(function* (ctx) {
           const custom: Tool.Info[] = []
@@ -82,35 +85,34 @@ export namespace ToolRegistry {
             }
           }
 
-          yield* Effect.promise(async () => {
-            const matches = await Config.directories().then((dirs) =>
-              dirs.flatMap((dir) =>
-                Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
-              ),
+          const dirs = yield* config.directories()
+          const matches = dirs.flatMap((dir) =>
+            Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
+          )
+          if (matches.length) yield* config.waitForDependencies()
+          for (const match of matches) {
+            const namespace = path.basename(match, path.extname(match))
+            const mod = yield* Effect.promise(
+              () => import(process.platform === "win32" ? match : pathToFileURL(match).href),
             )
-            if (matches.length) await Config.waitForDependencies()
-            for (const match of matches) {
-              const namespace = path.basename(match, path.extname(match))
-              const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
-              for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
-                custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
-              }
+            for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
+              custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
             }
+          }
 
-            const plugins = await Plugin.list()
-            for (const plugin of plugins) {
-              for (const [id, def] of Object.entries(plugin.tool ?? {})) {
-                custom.push(fromPlugin(id, def))
-              }
+          const plugins = yield* plugin.list()
+          for (const p of plugins) {
+            for (const [id, def] of Object.entries(p.tool ?? {})) {
+              custom.push(fromPlugin(id, def))
             }
-          })
+          }
 
           return { custom }
         }),
       )
 
-      async function all(custom: Tool.Info[]): Promise<Tool.Info[]> {
-        const cfg = await Config.get()
+      const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
+        const cfg = yield* config.get()
         const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
 
         return [
@@ -134,7 +136,7 @@ export namespace ToolRegistry {
           ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
           ...custom,
         ]
-      }
+      })
 
       const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
         const state = yield* InstanceState.get(cache)
@@ -148,7 +150,7 @@ export namespace ToolRegistry {
 
       const ids = Effect.fn("ToolRegistry.ids")(function* () {
         const state = yield* InstanceState.get(cache)
-        const tools = yield* Effect.promise(() => all(state.custom))
+        const tools = yield* all(state.custom)
         return tools.map((t) => t.id)
       })
 
@@ -157,40 +159,37 @@ export namespace ToolRegistry {
         agent?: Agent.Info,
       ) {
         const state = yield* InstanceState.get(cache)
-        const allTools = yield* Effect.promise(() => all(state.custom))
-        return yield* Effect.promise(() =>
-          Promise.all(
-            allTools
-              .filter((tool) => {
-                // Enable websearch/codesearch for zen users OR via enable flag
-                if (tool.id === "codesearch" || tool.id === "websearch") {
-                  return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
-                }
-
-                // use apply tool in same format as codex
-                const usePatch =
-                  model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
-                if (tool.id === "apply_patch") return usePatch
-                if (tool.id === "edit" || tool.id === "write") return !usePatch
-
-                return true
-              })
-              .map(async (tool) => {
-                using _ = log.time(tool.id)
-                const next = await tool.init({ agent })
-                const output = {
-                  description: next.description,
-                  parameters: next.parameters,
-                }
-                await Plugin.trigger("tool.definition", { toolID: tool.id }, output)
-                return {
-                  id: tool.id,
-                  ...next,
-                  description: output.description,
-                  parameters: output.parameters,
-                }
-              }),
-          ),
+        const allTools = yield* all(state.custom)
+        const filtered = allTools.filter((tool) => {
+          if (tool.id === "codesearch" || tool.id === "websearch") {
+            return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
+          }
+
+          const usePatch =
+            model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
+          if (tool.id === "apply_patch") return usePatch
+          if (tool.id === "edit" || tool.id === "write") return !usePatch
+
+          return true
+        })
+        return yield* Effect.forEach(
+          filtered,
+          Effect.fnUntraced(function* (tool) {
+            using _ = log.time(tool.id)
+            const next = yield* Effect.promise(() => tool.init({ agent }))
+            const output = {
+              description: next.description,
+              parameters: next.parameters,
+            }
+            yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
+            return {
+              id: tool.id,
+              ...next,
+              description: output.description,
+              parameters: output.parameters,
+            } as Awaited<ReturnType<Tool.Info["init"]>> & { id: string }
+          }),
+          { concurrency: "unbounded" },
         )
       })
 
@@ -198,7 +197,11 @@ export namespace ToolRegistry {
     }),
   )
 
-  const { runPromise } = makeRuntime(Service, layer)
+  export const defaultLayer = Layer.unwrap(
+    Effect.sync(() => layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Plugin.defaultLayer))),
+  )
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function register(tool: Tool.Info) {
     return runPromise((svc) => svc.register(tool))
@@ -214,7 +217,7 @@ export namespace ToolRegistry {
       modelID: ModelID
     },
     agent?: Agent.Info,
-  ) {
+  ): Promise<(Awaited<ReturnType<Tool.Info["init"]>> & { id: string })[]> {
     return runPromise((svc) => svc.tools(model, agent))
   }
 }

+ 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)
+}

+ 24 - 17
packages/opencode/src/worktree/index.ts

@@ -9,11 +9,13 @@ 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, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
+import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
-import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import { NodePath } from "@effect/platform-node"
+import { AppFileSystem } from "@/filesystem"
 import { makeRuntime } from "@/effect/run-service"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 
@@ -167,14 +169,15 @@ export namespace Worktree {
   export const layer: Layer.Layer<
     Service,
     never,
-    FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
+    AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service
   > = Layer.effect(
     Service,
     Effect.gen(function* () {
       const scope = yield* Scope.Scope
-      const fsys = yield* FileSystem.FileSystem
+      const fs = yield* AppFileSystem.Service
       const pathSvc = yield* Path.Path
       const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+      const project = yield* Project.Service
 
       const git = Effect.fnUntraced(
         function* (args: string[], opts?: { cwd?: string }) {
@@ -201,7 +204,7 @@ export namespace Worktree {
           const branch = `opencode/${name}`
           const directory = pathSvc.join(root, name)
 
-          if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
+          if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
 
           const ref = `refs/heads/${branch}`
           const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
@@ -218,7 +221,7 @@ export namespace Worktree {
         }
 
         const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
-        yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
+        yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
 
         const base = name ? slugify(name) : ""
         return yield* candidate(root, base || undefined)
@@ -232,7 +235,7 @@ export namespace Worktree {
           throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
         }
 
-        yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
+        yield* project.addSandbox(Instance.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
       })
 
       const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
@@ -258,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,
@@ -297,7 +300,7 @@ export namespace Worktree {
 
       const canonical = Effect.fnUntraced(function* (input: string) {
         const abs = pathSvc.resolve(input)
-        const real = yield* fsys.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
+        const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
         const normalized = pathSvc.normalize(real)
         return process.platform === "win32" ? normalized.toLowerCase() : normalized
       })
@@ -334,7 +337,7 @@ export namespace Worktree {
       })
 
       function stopFsmonitor(target: string) {
-        return fsys.exists(target).pipe(
+        return fs.exists(target).pipe(
           Effect.orDie,
           Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
         )
@@ -342,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" })
+            }),
         )
       }
 
@@ -364,7 +370,7 @@ export namespace Worktree {
         const entry = yield* locateWorktree(entries, directory)
 
         if (!entry?.path) {
-          const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
+          const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie)
           if (directoryExists) {
             yield* stopFsmonitor(directory)
             yield* cleanDirectory(directory)
@@ -464,7 +470,7 @@ export namespace Worktree {
               const target = yield* canonical(pathSvc.resolve(root, entry))
               if (target === base) return
               if (!target.startsWith(`${base}${pathSvc.sep}`)) return
-              yield* fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
+              yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore)
             }),
           { concurrency: "unbounded" },
         )
@@ -603,8 +609,9 @@ export namespace Worktree {
   )
 
   const defaultLayer = layer.pipe(
-    Layer.provide(CrossSpawnSpawner.layer),
-    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
+    Layer.provide(Project.defaultLayer),
+    Layer.provide(AppFileSystem.defaultLayer),
     Layer.provide(NodePath.layer),
   )
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 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: "[email protected]" }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
+    plugin_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: "[email protected]" }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin: [tmp.extra.spec],
+    plugin_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
+  }
+})

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

@@ -0,0 +1,51 @@
+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")
+})

+ 283 - 147
packages/opencode/test/config/config.test.ts

@@ -1,15 +1,35 @@
-import { test, expect, describe, mock, afterEach } from "bun:test"
+import { test, expect, describe, mock, afterEach, spyOn } from "bun:test"
+import { Effect, Layer, Option } from "effect"
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Config } from "../../src/config/config"
 import { Instance } from "../../src/project/instance"
 import { Auth } from "../../src/auth"
 import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
+import { AppFileSystem } from "../../src/filesystem"
+import { provideTmpdirInstance } from "../fixture/fixture"
 import { tmpdir } from "../fixture/fixture"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+
+/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
+const infra = CrossSpawnSpawner.defaultLayer.pipe(
+  Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
+)
 import path from "path"
 import fs from "fs/promises"
 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)({
+  active: () => Effect.succeed(Option.none()),
+})
+
+const emptyAuth = Layer.mock(Auth.Service)({
+  all: () => Effect.succeed({}),
+})
 
 // Get managed config directory from environment (set in preload.ts)
 const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@@ -245,43 +265,44 @@ test("preserves env variables when adding $schema to config", async () => {
 })
 
 test("resolves env templates in account config with account token", async () => {
-  const originalActive = Account.active
-  const originalConfig = Account.config
-  const originalToken = Account.token
   const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
 
-  Account.active = mock(async () => ({
-    id: AccountID.make("account-1"),
-    email: "[email protected]",
-    url: "https://control.example.com",
-    active_org_id: OrgID.make("org-1"),
-  }))
-
-  Account.config = mock(async () => ({
-    provider: {
-      opencode: {
-        options: {
-          apiKey: "{env:OPENCODE_CONSOLE_TOKEN}",
-        },
-      },
-    },
-  }))
+  const fakeAccount = Layer.mock(Account.Service)({
+    active: () =>
+      Effect.succeed(
+        Option.some({
+          id: AccountID.make("account-1"),
+          email: "[email protected]",
+          url: "https://control.example.com",
+          active_org_id: OrgID.make("org-1"),
+        }),
+      ),
+    config: () =>
+      Effect.succeed(
+        Option.some({
+          provider: { opencode: { options: { apiKey: "{env:OPENCODE_CONSOLE_TOKEN}" } } },
+        }),
+      ),
+    token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
+  })
 
-  Account.token = mock(async () => AccessToken.make("st_test_token"))
+  const layer = Config.layer.pipe(
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(emptyAuth),
+    Layer.provide(fakeAccount),
+    Layer.provideMerge(infra),
+  )
 
   try {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const config = await Config.get()
-        expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
-      },
-    })
+    await provideTmpdirInstance(() =>
+      Config.Service.use((svc) =>
+        Effect.gen(function* () {
+          const config = yield* svc.get()
+          expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
+        }),
+      ),
+    ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
   } finally {
-    Account.active = originalActive
-    Account.config = originalConfig
-    Account.token = originalToken
     if (originalControlToken !== undefined) {
       process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
     } else {
@@ -745,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({
@@ -758,11 +793,132 @@ 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("dedupes concurrent config dependency installs for the same dir", async () => {
+  await using tmp = await tmpdir()
+  const dir = path.join(tmp.path, "a")
+  await fs.mkdir(dir, { recursive: true })
+
+  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) => {
+    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),
+      stderr: Buffer.alloc(0),
+    }
+  })
+
+  try {
+    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(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 () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
@@ -802,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")
     },
   })
 })
@@ -1554,7 +1702,7 @@ test("local .opencode config can override MCP from project config", async () =>
 test("project config overrides remote well-known config", async () => {
   const originalFetch = globalThis.fetch
   let fetchedUrl: string | undefined
-  const mockFetch = mock((url: string | URL | Request) => {
+  globalThis.fetch = mock((url: string | URL | Request) => {
     const urlStr = url.toString()
     if (urlStr.includes(".well-known/opencode")) {
       fetchedUrl = urlStr
@@ -1562,13 +1710,7 @@ test("project config overrides remote well-known config", async () => {
         new Response(
           JSON.stringify({
             config: {
-              mcp: {
-                jira: {
-                  type: "remote",
-                  url: "https://jira.example.com/mcp",
-                  enabled: false,
-                },
-              },
+              mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
             },
           }),
           { status: 200 },
@@ -1576,60 +1718,46 @@ test("project config overrides remote well-known config", async () => {
       )
     }
     return originalFetch(url)
+  }) as unknown as typeof fetch
+
+  const fakeAuth = Layer.mock(Auth.Service)({
+    all: () =>
+      Effect.succeed({
+        "https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
+      }),
   })
-  globalThis.fetch = mockFetch as unknown as typeof fetch
 
-  const originalAuthAll = Auth.all
-  Auth.all = mock(() =>
-    Promise.resolve({
-      "https://example.com": {
-        type: "wellknown" as const,
-        key: "TEST_TOKEN",
-        token: "test-token",
-      },
-    }),
+  const layer = Config.layer.pipe(
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(fakeAuth),
+    Layer.provide(emptyAccount),
+    Layer.provideMerge(infra),
   )
 
   try {
-    await using tmp = await tmpdir({
-      git: true,
-      init: async (dir) => {
-        // Project config enables jira (overriding remote default)
-        await Filesystem.write(
-          path.join(dir, "opencode.json"),
-          JSON.stringify({
-            $schema: "https://opencode.ai/config.json",
-            mcp: {
-              jira: {
-                type: "remote",
-                url: "https://jira.example.com/mcp",
-                enabled: true,
-              },
-            },
+    await provideTmpdirInstance(
+      () =>
+        Config.Service.use((svc) =>
+          Effect.gen(function* () {
+            const config = yield* svc.get()
+            expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
+            expect(config.mcp?.jira?.enabled).toBe(true)
           }),
-        )
-      },
-    })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const config = await Config.get()
-        // Verify fetch was called for wellknown config
-        expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
-        // Project config (enabled: true) should override remote (enabled: false)
-        expect(config.mcp?.jira?.enabled).toBe(true)
+        ),
+      {
+        git: true,
+        config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
       },
-    })
+    ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
   } finally {
     globalThis.fetch = originalFetch
-    Auth.all = originalAuthAll
   }
 })
 
 test("wellknown URL with trailing slash is normalized", async () => {
   const originalFetch = globalThis.fetch
   let fetchedUrl: string | undefined
-  const mockFetch = mock((url: string | URL | Request) => {
+  globalThis.fetch = mock((url: string | URL | Request) => {
     const urlStr = url.toString()
     if (urlStr.includes(".well-known/opencode")) {
       fetchedUrl = urlStr
@@ -1637,13 +1765,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
         new Response(
           JSON.stringify({
             config: {
-              mcp: {
-                slack: {
-                  type: "remote",
-                  url: "https://slack.example.com/mcp",
-                  enabled: true,
-                },
-              },
+              mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
             },
           }),
           { status: 200 },
@@ -1651,67 +1773,75 @@ test("wellknown URL with trailing slash is normalized", async () => {
       )
     }
     return originalFetch(url)
+  }) as unknown as typeof fetch
+
+  const fakeAuth = Layer.mock(Auth.Service)({
+    all: () =>
+      Effect.succeed({
+        "https://example.com/": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
+      }),
   })
-  globalThis.fetch = mockFetch as unknown as typeof fetch
 
-  const originalAuthAll = Auth.all
-  Auth.all = mock(() =>
-    Promise.resolve({
-      "https://example.com/": {
-        type: "wellknown" as const,
-        key: "TEST_TOKEN",
-        token: "test-token",
-      },
-    }),
+  const layer = Config.layer.pipe(
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(fakeAuth),
+    Layer.provide(emptyAccount),
+    Layer.provideMerge(infra),
   )
 
   try {
-    await using tmp = await tmpdir({
-      git: true,
-      init: async (dir) => {
-        await Filesystem.write(
-          path.join(dir, "opencode.json"),
-          JSON.stringify({
-            $schema: "https://opencode.ai/config.json",
+    await provideTmpdirInstance(
+      () =>
+        Config.Service.use((svc) =>
+          Effect.gen(function* () {
+            yield* svc.get()
+            expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
           }),
-        )
-      },
-    })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        await Config.get()
-        // Trailing slash should be stripped — no double slash in the fetch URL
-        expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
-      },
-    })
+        ),
+      { git: true },
+    ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
   } finally {
     globalThis.fetch = originalFetch
-    Auth.all = originalAuthAll
   }
 })
 
-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("[email protected]", file)).toBe("[email protected]")
+    expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
   })
 
-  test("extracts name from npm package with version", () => {
-    expect(Config.getPluginName("[email protected]")).toBe("oh-my-opencode")
-    expect(Config.getPluginName("[email protected]")).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/[email protected]")).toBe("@scope/pkg")
-    expect(Config.getPluginName("@opencode/[email protected]")).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)
   })
 })
 
@@ -1728,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 = ["[email protected]", "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", () => {
@@ -1745,7 +1882,7 @@ describe("deduplicatePlugins", () => {
     expect(result).toEqual(["[email protected]", "[email protected]", "[email protected]"])
   })
 
-  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")
@@ -1771,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) === "[email protected]")).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: ["[email protected]"] }, 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: ["[email protected]"] }, 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(["[email protected]"])
+      expect(config.plugin_meta).toEqual({
+        "[email protected]": {
+          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: [["[email protected]", { enabled: true, label: "demo" }]],
+        }),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.plugin).toEqual([["[email protected]", { enabled: true, label: "demo" }]])
+      expect(config.plugin_meta).toEqual({
+        "[email protected]": {
+          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: [["[email protected]", { source: "global" }]],
+        }),
+      )
+      await Bun.write(
+        path.join(dir, "tui.json"),
+        JSON.stringify({
+          plugin: [
+            ["[email protected]", { source: "project" }],
+            ["[email protected]", { source: "project" }],
+          ],
+        }),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.plugin).toEqual([
+        ["[email protected]", { source: "project" }],
+        ["[email protected]", { source: "project" }],
+      ])
+      expect(config.plugin_meta).toEqual({
+        "[email protected]": {
+          scope: "local",
+          source: path.join(tmp.path, "tui.json"),
+        },
+        "[email protected]": {
+          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: ["[email protected]"],
+        }),
+      )
+      await Bun.write(
+        path.join(dir, "tui.json"),
+        JSON.stringify({
+          plugin: ["[email protected]"],
+        }),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.plugin).toEqual(["[email protected]", "[email protected]"])
+      expect(config.plugin_meta).toEqual({
+        "[email protected]": {
+          scope: "global",
+          source: path.join(Global.Path.config, "tui.json"),
+        },
+        "[email protected]": {
+          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,
+      })
+    },
+  })
+})

+ 1 - 1
packages/opencode/test/effect/cross-spawn-spawner.test.ts

@@ -9,7 +9,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { tmpdir } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 
-const live = CrossSpawnSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
+const live = CrossSpawnSpawner.defaultLayer
 const fx = testEffect(live)
 
 function js(code: string, opts?: ChildProcess.CommandOptions) {

+ 2 - 0
packages/opencode/test/file/watcher.test.ts

@@ -5,6 +5,7 @@ import path from "path"
 import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect"
 import { tmpdir } from "../fixture/fixture"
 import { Bus } from "../../src/bus"
+import { Config } from "../../src/config/config"
 import { FileWatcher } from "../../src/file/watcher"
 import { Instance } from "../../src/project/instance"
 
@@ -30,6 +31,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
     directory,
     fn: async () => {
       const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
+        Layer.provide(Config.defaultLayer),
         Layer.provide(watcherConfigLayer),
       )
       const rt = ManagedRuntime.make(layer)

+ 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
+      },
+    },
+  }
+}

Some files were not shown because too many files changed in this diff