agent-mockingbird 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/.agents/skills/btca-cli/SKILL.md +64 -0
  2. package/.agents/skills/btca-cli/agents/openai.yaml +3 -0
  3. package/.agents/skills/frontend-design/SKILL.md +42 -0
  4. package/.agents/skills/frontend-design/agents/openai.yaml +3 -0
  5. package/.env.example +36 -0
  6. package/.githooks/pre-commit +33 -0
  7. package/.github/workflows/ci.yml +309 -0
  8. package/.opencode/bun.lock +18 -0
  9. package/.opencode/package.json +5 -0
  10. package/.opencode/tools/agent_type_manager.ts +100 -0
  11. package/.opencode/tools/config_manager.ts +87 -0
  12. package/.opencode/tools/cron_manager.ts +145 -0
  13. package/.opencode/tools/memory_get.ts +43 -0
  14. package/.opencode/tools/memory_remember.ts +53 -0
  15. package/.opencode/tools/memory_search.ts +48 -0
  16. package/AGENTS.md +126 -0
  17. package/MEMORY.md +2 -0
  18. package/README.md +451 -0
  19. package/THIRD_PARTY_NOTICES.md +11 -0
  20. package/agent-mockingbird.config.example.json +135 -0
  21. package/apps/server/package.json +32 -0
  22. package/apps/server/src/backend/agents/bootstrapContext.ts +362 -0
  23. package/apps/server/src/backend/agents/openclawImport.test.ts +133 -0
  24. package/apps/server/src/backend/agents/openclawImport.ts +797 -0
  25. package/apps/server/src/backend/agents/opencodeConfig.ts +428 -0
  26. package/apps/server/src/backend/agents/service.ts +10 -0
  27. package/apps/server/src/backend/config/example-config.test.ts +20 -0
  28. package/apps/server/src/backend/config/orchestration.ts +243 -0
  29. package/apps/server/src/backend/config/policy.ts +158 -0
  30. package/apps/server/src/backend/config/schema.test.ts +15 -0
  31. package/apps/server/src/backend/config/schema.ts +391 -0
  32. package/apps/server/src/backend/config/semantic.test.ts +34 -0
  33. package/apps/server/src/backend/config/semantic.ts +149 -0
  34. package/apps/server/src/backend/config/service.test.ts +75 -0
  35. package/apps/server/src/backend/config/service.ts +207 -0
  36. package/apps/server/src/backend/config/smoke.ts +77 -0
  37. package/apps/server/src/backend/config/store.test.ts +123 -0
  38. package/apps/server/src/backend/config/store.ts +581 -0
  39. package/apps/server/src/backend/config/testFixtures.ts +5 -0
  40. package/apps/server/src/backend/config/types.ts +56 -0
  41. package/apps/server/src/backend/contracts/events.ts +320 -0
  42. package/apps/server/src/backend/contracts/runtime.ts +111 -0
  43. package/apps/server/src/backend/cron/executor.ts +435 -0
  44. package/apps/server/src/backend/cron/repository.ts +170 -0
  45. package/apps/server/src/backend/cron/service.ts +660 -0
  46. package/apps/server/src/backend/cron/storage.ts +92 -0
  47. package/apps/server/src/backend/cron/types.ts +138 -0
  48. package/apps/server/src/backend/cron/utils.ts +351 -0
  49. package/apps/server/src/backend/db/client.ts +20 -0
  50. package/apps/server/src/backend/db/migrate.ts +40 -0
  51. package/apps/server/src/backend/db/repository.ts +1762 -0
  52. package/apps/server/src/backend/db/schema.ts +113 -0
  53. package/apps/server/src/backend/db/usageDashboard.test.ts +102 -0
  54. package/apps/server/src/backend/db/wipe.ts +13 -0
  55. package/apps/server/src/backend/defaults.ts +32 -0
  56. package/apps/server/src/backend/env.ts +48 -0
  57. package/apps/server/src/backend/heartbeat/activeHours.ts +45 -0
  58. package/apps/server/src/backend/heartbeat/defaultJob.ts +88 -0
  59. package/apps/server/src/backend/heartbeat/heartbeat.test.ts +110 -0
  60. package/apps/server/src/backend/heartbeat/runtimeService.ts +190 -0
  61. package/apps/server/src/backend/heartbeat/service.ts +176 -0
  62. package/apps/server/src/backend/heartbeat/state.test.ts +63 -0
  63. package/apps/server/src/backend/heartbeat/state.ts +167 -0
  64. package/apps/server/src/backend/heartbeat/types.ts +54 -0
  65. package/apps/server/src/backend/http/boundedQueue.test.ts +49 -0
  66. package/apps/server/src/backend/http/boundedQueue.ts +92 -0
  67. package/apps/server/src/backend/http/parsers.ts +40 -0
  68. package/apps/server/src/backend/http/router.ts +61 -0
  69. package/apps/server/src/backend/http/routes/agentRoutes.ts +67 -0
  70. package/apps/server/src/backend/http/routes/backgroundRoutes.ts +203 -0
  71. package/apps/server/src/backend/http/routes/chatRoutes.ts +107 -0
  72. package/apps/server/src/backend/http/routes/configRoutes.ts +602 -0
  73. package/apps/server/src/backend/http/routes/cronRoutes.ts +221 -0
  74. package/apps/server/src/backend/http/routes/dashboardRoutes.ts +308 -0
  75. package/apps/server/src/backend/http/routes/eventRoutes.ts +7 -0
  76. package/apps/server/src/backend/http/routes/heartbeatRoutes.test.ts +41 -0
  77. package/apps/server/src/backend/http/routes/heartbeatRoutes.ts +28 -0
  78. package/apps/server/src/backend/http/routes/index.ts +101 -0
  79. package/apps/server/src/backend/http/routes/mcpRoutes.ts +213 -0
  80. package/apps/server/src/backend/http/routes/memoryRoutes.ts +154 -0
  81. package/apps/server/src/backend/http/routes/runRoutes.ts +310 -0
  82. package/apps/server/src/backend/http/routes/runtimeRoutes.ts +197 -0
  83. package/apps/server/src/backend/http/routes/skillRoutes.ts +112 -0
  84. package/apps/server/src/backend/http/routes/uiRoutes.test.ts +161 -0
  85. package/apps/server/src/backend/http/routes/uiRoutes.ts +177 -0
  86. package/apps/server/src/backend/http/routes/usageRoutes.test.ts +104 -0
  87. package/apps/server/src/backend/http/routes/usageRoutes.ts +767 -0
  88. package/apps/server/src/backend/http/schemas.ts +64 -0
  89. package/apps/server/src/backend/http/sse.ts +144 -0
  90. package/apps/server/src/backend/integration/backend-core.test.ts +2316 -0
  91. package/apps/server/src/backend/logging/logger.ts +64 -0
  92. package/apps/server/src/backend/mcp/service.ts +326 -0
  93. package/apps/server/src/backend/memory/cli.ts +170 -0
  94. package/apps/server/src/backend/memory/conceptExpansion.test.ts +28 -0
  95. package/apps/server/src/backend/memory/conceptExpansion.ts +80 -0
  96. package/apps/server/src/backend/memory/qmdPort.test.ts +54 -0
  97. package/apps/server/src/backend/memory/qmdPort.ts +61 -0
  98. package/apps/server/src/backend/memory/records.test.ts +66 -0
  99. package/apps/server/src/backend/memory/records.ts +229 -0
  100. package/apps/server/src/backend/memory/service.ts +2012 -0
  101. package/apps/server/src/backend/memory/sqliteVec.ts +58 -0
  102. package/apps/server/src/backend/memory/types.ts +104 -0
  103. package/apps/server/src/backend/opencode/agentMockingbirdPlugin.test.ts +396 -0
  104. package/apps/server/src/backend/opencode/client.ts +98 -0
  105. package/apps/server/src/backend/opencode/models.ts +41 -0
  106. package/apps/server/src/backend/opencode/systemPrompt.test.ts +146 -0
  107. package/apps/server/src/backend/opencode/systemPrompt.ts +284 -0
  108. package/apps/server/src/backend/paths.ts +57 -0
  109. package/apps/server/src/backend/prompts/service.ts +100 -0
  110. package/apps/server/src/backend/queue/queue.test.ts +189 -0
  111. package/apps/server/src/backend/queue/service.ts +177 -0
  112. package/apps/server/src/backend/queue/types.ts +39 -0
  113. package/apps/server/src/backend/run/service.ts +576 -0
  114. package/apps/server/src/backend/run/storage.ts +47 -0
  115. package/apps/server/src/backend/run/types.ts +44 -0
  116. package/apps/server/src/backend/runtime/errors.ts +61 -0
  117. package/apps/server/src/backend/runtime/index.ts +72 -0
  118. package/apps/server/src/backend/runtime/memoryPromptDedup.test.ts +153 -0
  119. package/apps/server/src/backend/runtime/memoryPromptDedup.ts +76 -0
  120. package/apps/server/src/backend/runtime/opencodeRuntime/backgroundMethods.ts +765 -0
  121. package/apps/server/src/backend/runtime/opencodeRuntime/coreMethods.ts +705 -0
  122. package/apps/server/src/backend/runtime/opencodeRuntime/eventMethods.ts +503 -0
  123. package/apps/server/src/backend/runtime/opencodeRuntime/memoryMethods.ts +462 -0
  124. package/apps/server/src/backend/runtime/opencodeRuntime/promptMethods.ts +1167 -0
  125. package/apps/server/src/backend/runtime/opencodeRuntime/shared.ts +254 -0
  126. package/apps/server/src/backend/runtime/opencodeRuntime.test.ts +2899 -0
  127. package/apps/server/src/backend/runtime/opencodeRuntime.ts +135 -0
  128. package/apps/server/src/backend/runtime/sessionScope.ts +45 -0
  129. package/apps/server/src/backend/skills/service.ts +442 -0
  130. package/apps/server/src/backend/workspace/resolve.ts +27 -0
  131. package/apps/server/src/cli/agent-mockingbird.mjs +2522 -0
  132. package/apps/server/src/cli/agent-mockingbird.test.ts +68 -0
  133. package/apps/server/src/cli/runtime-assets.mjs +269 -0
  134. package/apps/server/src/cli/runtime-assets.test.ts +52 -0
  135. package/apps/server/src/cli/runtime-layout.mjs +75 -0
  136. package/apps/server/src/cli/standaloneBuild.test.ts +19 -0
  137. package/apps/server/src/cli/standaloneBuild.ts +19 -0
  138. package/apps/server/src/cli/standaloneCronBinary.test.ts +187 -0
  139. package/apps/server/src/index.ts +178 -0
  140. package/apps/server/tsconfig.json +12 -0
  141. package/backlog.md +5 -0
  142. package/bin/agent-mockingbird +2522 -0
  143. package/bin/runtime-layout.mjs +75 -0
  144. package/build-bin.ts +34 -0
  145. package/build-cli.mjs +37 -0
  146. package/build.ts +40 -0
  147. package/bun-env.d.ts +11 -0
  148. package/bun.lock +888 -0
  149. package/bunfig.toml +2 -0
  150. package/components.json +21 -0
  151. package/config.json +130 -0
  152. package/deploy/RELEASE_INSTALL.md +112 -0
  153. package/deploy/docker-compose.yml +42 -0
  154. package/deploy/systemd/README.md +46 -0
  155. package/deploy/systemd/agent-mockingbird.service +28 -0
  156. package/deploy/systemd/opencode.service +25 -0
  157. package/docs/legacy-config-ui-reference.md +51 -0
  158. package/docs/memory-e2e-trace-2026-03-04.md +63 -0
  159. package/docs/memory-ops.md +96 -0
  160. package/docs/memory-runtime-contract.md +42 -0
  161. package/docs/memory-tuning-remote-2026-03-04.md +59 -0
  162. package/docs/opencode-rebase-workflow-plan.md +614 -0
  163. package/docs/opencode-startup-sync-plan.md +94 -0
  164. package/docs/vendor-opencode.md +41 -0
  165. package/drizzle/0000_famous_turbo.sql +49 -0
  166. package/drizzle/0001_cron_memory_aux.sql +160 -0
  167. package/drizzle/0002_runtime_session_bindings.sql +28 -0
  168. package/drizzle/0003_background_runs.sql +27 -0
  169. package/drizzle/0004_memory_open_write.sql +63 -0
  170. package/drizzle/0005_signal_channel.sql +47 -0
  171. package/drizzle/0006_usage_event_dimensions.sql +7 -0
  172. package/drizzle/meta/0000_snapshot.json +341 -0
  173. package/drizzle/meta/_journal.json +55 -0
  174. package/drizzle.config.ts +14 -0
  175. package/eslint.config.mjs +77 -0
  176. package/knip.json +18 -0
  177. package/memory/2026-03-04.md +4 -0
  178. package/opencode.lock.json +16 -0
  179. package/package.json +67 -0
  180. package/packages/agent-mockingbird-installer/README.md +31 -0
  181. package/packages/agent-mockingbird-installer/bin/agent-mockingbird-installer.mjs +44 -0
  182. package/packages/agent-mockingbird-installer/opencode.lock.json +16 -0
  183. package/packages/agent-mockingbird-installer/package.json +23 -0
  184. package/packages/contracts/package.json +19 -0
  185. package/packages/contracts/src/agentTypes.ts +122 -0
  186. package/packages/contracts/src/cron.ts +146 -0
  187. package/packages/contracts/src/dashboard.ts +378 -0
  188. package/packages/contracts/src/index.ts +3 -0
  189. package/packages/contracts/tsconfig.json +4 -0
  190. package/patches/opencode/0001-Wafflebot-OpenCode-baseline.patch +2341 -0
  191. package/patches/opencode/0002-Fix-OpenCode-web-entry-and-settings-icons.patch +104 -0
  192. package/patches/opencode/0003-fix-app-remove-duplicate-sidebar-mount.patch +32 -0
  193. package/patches/opencode/0004-Add-heartbeat-settings-and-usage-nav.patch +506 -0
  194. package/patches/opencode/0005-Use-chart-icon-for-usage-nav.patch +38 -0
  195. package/patches/opencode/0006-Modernize-cron-settings.patch +399 -0
  196. package/patches/opencode/0007-Rename-waffle-namespaces-to-mockingbird.patch +1110 -0
  197. package/patches/opencode/0008-Remove-cron-contract-section.patch +178 -0
  198. package/patches/opencode/0009-Rework-cron-tab-as-operations-console.patch +414 -0
  199. package/patches/opencode/0010-Refine-heartbeat-settings-controls.patch +208 -0
  200. package/runtime-assets/opencode-config/opencode.jsonc +25 -0
  201. package/runtime-assets/opencode-config/package.json +5 -0
  202. package/runtime-assets/opencode-config/plugins/agent-mockingbird.ts +715 -0
  203. package/runtime-assets/workspace/.agents/skills/config-auditor/SKILL.md +25 -0
  204. package/runtime-assets/workspace/.agents/skills/config-editor/SKILL.md +24 -0
  205. package/runtime-assets/workspace/.agents/skills/cron-manager/SKILL.md +57 -0
  206. package/runtime-assets/workspace/.agents/skills/memory-ops/SKILL.md +120 -0
  207. package/runtime-assets/workspace/.agents/skills/runtime-diagnose/SKILL.md +25 -0
  208. package/runtime-assets/workspace/AGENTS.md +56 -0
  209. package/runtime-assets/workspace/MEMORY.md +4 -0
  210. package/scripts/build-release-bundle.sh +66 -0
  211. package/scripts/check-ship.ts +383 -0
  212. package/scripts/dev-opencode.sh +17 -0
  213. package/scripts/dev-stack-opencode.sh +15 -0
  214. package/scripts/dev-stack.sh +61 -0
  215. package/scripts/install-systemd.sh +87 -0
  216. package/scripts/memory-e2e.sh +76 -0
  217. package/scripts/memory-trace-e2e.sh +141 -0
  218. package/scripts/migrate-opencode-env.ts +108 -0
  219. package/scripts/onboard/bootstrap.sh +32 -0
  220. package/scripts/opencode-swap.ts +78 -0
  221. package/scripts/opencode-sync.ts +715 -0
  222. package/scripts/runtime-assets-sync.mjs +83 -0
  223. package/scripts/setup-git-hooks.ts +39 -0
  224. package/tsconfig.json +45 -0
  225. package/tui.json +98 -0
  226. package/turbo.json +36 -0
  227. package/vendor/OPENCODE_VENDOR.md +13 -0
@@ -0,0 +1,2341 @@
1
+ From a8c5e7494994dfd5ba79be9777a67dd9ddb6257d Mon Sep 17 00:00:00 2001
2
+ From: Matt Campbell <matt@battleshopper.com>
3
+ Date: Mon, 16 Mar 2026 14:05:14 -0500
4
+ Subject: [PATCH 01/10] Wafflebot OpenCode baseline
5
+
6
+ ---
7
+ packages/app/src/app.tsx | 7 +-
8
+ .../app/src/components/dialog-settings.tsx | 46 +++
9
+ .../app/src/components/settings-agents.tsx | 147 ++++++++
10
+ packages/app/src/components/settings-cron.tsx | 319 ++++++++++++++++++
11
+ packages/app/src/components/settings-mcp.tsx | 203 +++++++++++
12
+ .../app/src/components/settings-runtime.tsx | 120 +++++++
13
+ .../app/src/components/settings-skills.tsx | 256 ++++++++++++++
14
+ .../src/components/settings-waffle-shared.tsx | 116 +++++++
15
+ .../app/src/components/status-popover.tsx | 48 +--
16
+ packages/app/src/context/server.tsx | 17 +-
17
+ packages/app/src/entry.tsx | 31 +-
18
+ packages/app/src/i18n/en.ts | 8 +-
19
+ packages/app/src/pages/directory-layout.tsx | 11 +-
20
+ packages/app/src/pages/home.tsx | 140 ++------
21
+ packages/app/src/pages/layout.tsx | 89 ++---
22
+ .../app/src/pages/layout/sidebar-project.tsx | 23 --
23
+ .../app/src/pages/layout/sidebar-shell.tsx | 25 +-
24
+ packages/app/src/utils/waffle.ts | 61 ++++
25
+ packages/opencode/src/cli/cmd/providers.ts | 7 +-
26
+ packages/opencode/src/plugin/index.ts | 39 ++-
27
+ packages/opencode/src/provider/provider.ts | 1 +
28
+ packages/opencode/src/server/server.ts | 6 +-
29
+ .../test/cli/plugin-auth-picker.test.ts | 10 +
30
+ .../test/plugin/module-exports.test.ts | 39 +++
31
+ 24 files changed, 1463 insertions(+), 306 deletions(-)
32
+ create mode 100644 packages/app/src/components/settings-agents.tsx
33
+ create mode 100644 packages/app/src/components/settings-cron.tsx
34
+ create mode 100644 packages/app/src/components/settings-mcp.tsx
35
+ create mode 100644 packages/app/src/components/settings-runtime.tsx
36
+ create mode 100644 packages/app/src/components/settings-skills.tsx
37
+ create mode 100644 packages/app/src/components/settings-waffle-shared.tsx
38
+ create mode 100644 packages/app/src/utils/waffle.ts
39
+ create mode 100644 packages/opencode/test/plugin/module-exports.test.ts
40
+
41
+ diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
42
+ index e37086221..3dd67839b 100644
43
+ --- a/packages/app/src/app.tsx
44
+ +++ b/packages/app/src/app.tsx
45
+ @@ -269,11 +269,16 @@ export function AppInterface(props: {
46
+ children?: JSX.Element
47
+ defaultServer: ServerConnection.Key
48
+ servers?: Array<ServerConnection.Any>
49
+ + lockServerSelection?: boolean
50
+ router?: Component<BaseRouterProps>
51
+ disableHealthCheck?: boolean
52
+ }) {
53
+ return (
54
+ - <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
55
+ + <ServerProvider
56
+ + defaultServer={props.defaultServer}
57
+ + servers={props.servers}
58
+ + locked={props.lockServerSelection}
59
+ + >
60
+ <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
61
+ <GlobalSDKProvider>
62
+ <GlobalSyncProvider>
63
+ diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx
64
+ index 83cea131f..005920ccb 100644
65
+ --- a/packages/app/src/components/dialog-settings.tsx
66
+ +++ b/packages/app/src/components/dialog-settings.tsx
67
+ @@ -8,6 +8,11 @@ import { SettingsGeneral } from "./settings-general"
68
+ import { SettingsKeybinds } from "./settings-keybinds"
69
+ import { SettingsProviders } from "./settings-providers"
70
+ import { SettingsModels } from "./settings-models"
71
+ +import { SettingsAgents } from "./settings-agents"
72
+ +import { SettingsMcp } from "./settings-mcp"
73
+ +import { SettingsSkills } from "./settings-skills"
74
+ +import { SettingsRuntime } from "./settings-runtime"
75
+ +import { SettingsCron } from "./settings-cron"
76
+
77
+ export const DialogSettings: Component = () => {
78
+ const language = useLanguage()
79
+ @@ -47,6 +52,32 @@ export const DialogSettings: Component = () => {
80
+ </Tabs.Trigger>
81
+ </div>
82
+ </div>
83
+ +
84
+ + <div class="flex flex-col gap-1.5">
85
+ + <Tabs.SectionTitle>Agent Mockingbird</Tabs.SectionTitle>
86
+ + <div class="flex flex-col gap-1.5 w-full">
87
+ + <Tabs.Trigger value="agents">
88
+ + <Icon name="sparkles" />
89
+ + {language.t("settings.agents.title")}
90
+ + </Tabs.Trigger>
91
+ + <Tabs.Trigger value="mcp">
92
+ + <Icon name="plug-2" />
93
+ + {language.t("settings.mcp.title")}
94
+ + </Tabs.Trigger>
95
+ + <Tabs.Trigger value="skills">
96
+ + <Icon name="book-open" />
97
+ + {language.t("settings.skills.title")}
98
+ + </Tabs.Trigger>
99
+ + <Tabs.Trigger value="runtime">
100
+ + <Icon name="terminal-square" />
101
+ + {language.t("settings.runtime.title")}
102
+ + </Tabs.Trigger>
103
+ + <Tabs.Trigger value="cron">
104
+ + <Icon name="clock-3" />
105
+ + {language.t("settings.cron.title")}
106
+ + </Tabs.Trigger>
107
+ + </div>
108
+ + </div>
109
+ </div>
110
+ </div>
111
+ <div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
112
+ @@ -67,6 +98,21 @@ export const DialogSettings: Component = () => {
113
+ <Tabs.Content value="models" class="no-scrollbar">
114
+ <SettingsModels />
115
+ </Tabs.Content>
116
+ + <Tabs.Content value="agents" class="no-scrollbar">
117
+ + <SettingsAgents />
118
+ + </Tabs.Content>
119
+ + <Tabs.Content value="mcp" class="no-scrollbar">
120
+ + <SettingsMcp />
121
+ + </Tabs.Content>
122
+ + <Tabs.Content value="skills" class="no-scrollbar">
123
+ + <SettingsSkills />
124
+ + </Tabs.Content>
125
+ + <Tabs.Content value="runtime" class="no-scrollbar">
126
+ + <SettingsRuntime />
127
+ + </Tabs.Content>
128
+ + <Tabs.Content value="cron" class="no-scrollbar">
129
+ + <SettingsCron />
130
+ + </Tabs.Content>
131
+ </Tabs>
132
+ </Dialog>
133
+ )
134
+ diff --git a/packages/app/src/components/settings-agents.tsx b/packages/app/src/components/settings-agents.tsx
135
+ new file mode 100644
136
+ index 000000000..dda48a075
137
+ --- /dev/null
138
+ +++ b/packages/app/src/components/settings-agents.tsx
139
+ @@ -0,0 +1,147 @@
140
+ +import { Button } from "@opencode-ai/ui/button"
141
+ +import { showToast } from "@opencode-ai/ui/toast"
142
+ +import { onMount, Show, type Component } from "solid-js"
143
+ +import { createStore } from "solid-js/store"
144
+ +import { prettyJson, waffleJson } from "@/utils/waffle"
145
+ +import { WaffleCard, WaffleMetaRow, WaffleNotice, WaffleSettingsPage, WaffleSettingsSection, WaffleTextArea, WaffleToolbar } from "./settings-waffle-shared"
146
+ +
147
+ +type AgentType = {
148
+ + id: string
149
+ + name?: string
150
+ + description?: string
151
+ + prompt?: string
152
+ + model?: string
153
+ + variant?: string
154
+ + mode?: string
155
+ + hidden?: boolean
156
+ + disable?: boolean
157
+ + temperature?: number
158
+ + topP?: number
159
+ + steps?: number
160
+ + permission?: Record<string, unknown>
161
+ + options?: Record<string, unknown>
162
+ +}
163
+ +
164
+ +type AgentsPayload = {
165
+ + agentTypes: AgentType[]
166
+ + hash: string
167
+ + storage?: {
168
+ + directory: string
169
+ + configFilePath: string
170
+ + persistenceMode: string
171
+ + }
172
+ +}
173
+ +
174
+ +export const SettingsAgents: Component = () => {
175
+ + const [state, setState] = createStore({
176
+ + loading: true,
177
+ + saving: false,
178
+ + error: "",
179
+ + payload: null as AgentsPayload | null,
180
+ + text: "[]",
181
+ + })
182
+ +
183
+ + async function load() {
184
+ + setState("loading", true)
185
+ + setState("error", "")
186
+ + try {
187
+ + const payload = await waffleJson<AgentsPayload>("/api/waffle/agents")
188
+ + setState("payload", payload)
189
+ + setState("text", prettyJson(payload.agentTypes))
190
+ + } catch (error) {
191
+ + setState("error", error instanceof Error ? error.message : "Failed to load agent definitions")
192
+ + } finally {
193
+ + setState("loading", false)
194
+ + }
195
+ + }
196
+ +
197
+ + async function save() {
198
+ + let parsed: AgentType[]
199
+ + try {
200
+ + parsed = JSON.parse(state.text) as AgentType[]
201
+ + } catch (error) {
202
+ + setState("error", error instanceof Error ? error.message : "Invalid agent JSON")
203
+ + return
204
+ + }
205
+ +
206
+ + if (!Array.isArray(parsed)) {
207
+ + setState("error", "Agent payload must be a JSON array.")
208
+ + return
209
+ + }
210
+ +
211
+ + const previous = state.payload?.agentTypes ?? []
212
+ + const previousIDs = new Set(previous.map((item) => item.id))
213
+ + const nextIDs = new Set(parsed.map((item) => item.id))
214
+ + const deletes = [...previousIDs].filter((id) => !nextIDs.has(id))
215
+ +
216
+ + setState("saving", true)
217
+ + setState("error", "")
218
+ + try {
219
+ + const payload = await waffleJson<AgentsPayload>("/api/waffle/agents", {
220
+ + method: "PATCH",
221
+ + body: JSON.stringify({
222
+ + upserts: parsed,
223
+ + deletes,
224
+ + expectedHash: state.payload?.hash,
225
+ + }),
226
+ + })
227
+ + setState("payload", payload)
228
+ + setState("text", prettyJson(payload.agentTypes))
229
+ + showToast({
230
+ + variant: "success",
231
+ + icon: "circle-check",
232
+ + title: "Agent definitions saved",
233
+ + })
234
+ + } catch (error) {
235
+ + setState("error", error instanceof Error ? error.message : "Failed to save agent definitions")
236
+ + } finally {
237
+ + setState("saving", false)
238
+ + }
239
+ + }
240
+ +
241
+ + onMount(() => {
242
+ + void load()
243
+ + })
244
+ +
245
+ + return (
246
+ + <WaffleSettingsPage
247
+ + title="Agents"
248
+ + description="Manage OpenCode agent definitions stored in the pinned workspace's .opencode/opencode.jsonc."
249
+ + actions={
250
+ + <WaffleToolbar>
251
+ + <Button variant="ghost" size="large" onClick={() => void load()} disabled={state.loading || state.saving}>
252
+ + Refresh
253
+ + </Button>
254
+ + <Button size="large" onClick={() => void save()} disabled={state.loading || state.saving}>
255
+ + Save
256
+ + </Button>
257
+ + </WaffleToolbar>
258
+ + }
259
+ + >
260
+ + <Show when={state.error}>
261
+ + <WaffleNotice tone="error">{state.error}</WaffleNotice>
262
+ + </Show>
263
+ +
264
+ + <Show when={state.payload?.storage}>
265
+ + {(storage) => (
266
+ + <WaffleSettingsSection title="Storage">
267
+ + <WaffleMetaRow label="Workspace directory" value={storage().directory} />
268
+ + <WaffleMetaRow label="Config file" value={storage().configFilePath} />
269
+ + <WaffleMetaRow label="Persistence mode" value={storage().persistenceMode} />
270
+ + </WaffleSettingsSection>
271
+ + )}
272
+ + </Show>
273
+ +
274
+ + <WaffleSettingsSection title="Agent editor" description="Edit the current agent type array as JSON.">
275
+ + <WaffleCard>
276
+ + <WaffleTextArea
277
+ + rows={22}
278
+ + value={state.text}
279
+ + onInput={(event) => setState("text", event.currentTarget.value)}
280
+ + spellcheck={false}
281
+ + />
282
+ + </WaffleCard>
283
+ + </WaffleSettingsSection>
284
+ + </WaffleSettingsPage>
285
+ + )
286
+ +}
287
+ diff --git a/packages/app/src/components/settings-cron.tsx b/packages/app/src/components/settings-cron.tsx
288
+ new file mode 100644
289
+ index 000000000..8bda2bf9c
290
+ --- /dev/null
291
+ +++ b/packages/app/src/components/settings-cron.tsx
292
+ @@ -0,0 +1,319 @@
293
+ +import { Button } from "@opencode-ai/ui/button"
294
+ +import { showToast } from "@opencode-ai/ui/toast"
295
+ +import { createSignal, For, onMount, Show, type Component } from "solid-js"
296
+ +import { createStore } from "solid-js/store"
297
+ +import { prettyJson, waffleJson } from "@/utils/waffle"
298
+ +import { WaffleCard, WaffleMetaRow, WaffleNotice, WaffleSettingsPage, WaffleSettingsSection, WaffleTextArea, WaffleToolbar } from "./settings-waffle-shared"
299
+ +
300
+ +type CronJobDefinition = {
301
+ + id: string
302
+ + name: string
303
+ + enabled: boolean
304
+ + scheduleKind: "at" | "every" | "cron"
305
+ + scheduleExpr: string | null
306
+ + everyMs: number | null
307
+ + atIso: string | null
308
+ + timezone: string | null
309
+ + runMode: "background" | "conditional_agent" | "agent"
310
+ + handlerKey: string | null
311
+ + conditionModulePath: string | null
312
+ + conditionDescription: string | null
313
+ + agentPromptTemplate: string | null
314
+ + agentModelOverride: string | null
315
+ + maxAttempts: number
316
+ + retryBackoffMs: number
317
+ + payload: Record<string, unknown>
318
+ +}
319
+ +
320
+ +type CronHealthSnapshot = {
321
+ + enabled: boolean
322
+ + jobs: {
323
+ + total: number
324
+ + enabled: number
325
+ + }
326
+ +}
327
+ +
328
+ +const DEFAULT_JOB = prettyJson({
329
+ + name: "Daily summary",
330
+ + enabled: true,
331
+ + scheduleKind: "cron",
332
+ + scheduleExpr: "0 9 * * *",
333
+ + timezone: "America/Phoenix",
334
+ + runMode: "background",
335
+ + handlerKey: "daily_summary",
336
+ + payload: {},
337
+ +})
338
+ +
339
+ +export const SettingsCron: Component = () => {
340
+ + const [state, setState] = createStore({
341
+ + loading: true,
342
+ + saving: false,
343
+ + busyId: "",
344
+ + error: "",
345
+ + jobs: [] as CronJobDefinition[],
346
+ + handlers: [] as string[],
347
+ + health: null as CronHealthSnapshot | null,
348
+ + createText: DEFAULT_JOB,
349
+ + editingId: "",
350
+ + editText: "",
351
+ + })
352
+ + const [refreshTick, setRefreshTick] = createSignal(0)
353
+ +
354
+ + async function load() {
355
+ + refreshTick()
356
+ + setState("loading", true)
357
+ + setState("error", "")
358
+ + try {
359
+ + const [jobs, handlers, health] = await Promise.all([
360
+ + waffleJson<{ jobs: CronJobDefinition[] }>("/api/waffle/cron/jobs"),
361
+ + waffleJson<{ handlers: string[] }>("/api/waffle/cron/handlers"),
362
+ + waffleJson<{ health: CronHealthSnapshot }>("/api/waffle/cron/health"),
363
+ + ])
364
+ + setState("jobs", jobs.jobs)
365
+ + setState("handlers", handlers.handlers)
366
+ + setState("health", health.health)
367
+ + } catch (error) {
368
+ + setState("error", error instanceof Error ? error.message : "Failed to load cron jobs")
369
+ + } finally {
370
+ + setState("loading", false)
371
+ + }
372
+ + }
373
+ +
374
+ + async function refresh() {
375
+ + setRefreshTick((value) => value + 1)
376
+ + await load()
377
+ + }
378
+ +
379
+ + async function createJob() {
380
+ + let payload: unknown
381
+ + try {
382
+ + payload = JSON.parse(state.createText)
383
+ + } catch (error) {
384
+ + setState("error", error instanceof Error ? error.message : "Invalid cron job JSON")
385
+ + return
386
+ + }
387
+ +
388
+ + setState("saving", true)
389
+ + setState("error", "")
390
+ + try {
391
+ + await waffleJson("/api/waffle/cron/jobs", {
392
+ + method: "POST",
393
+ + body: JSON.stringify(payload),
394
+ + })
395
+ + showToast({
396
+ + variant: "success",
397
+ + icon: "circle-check",
398
+ + title: "Cron job created",
399
+ + })
400
+ + await refresh()
401
+ + } catch (error) {
402
+ + setState("error", error instanceof Error ? error.message : "Failed to create cron job")
403
+ + } finally {
404
+ + setState("saving", false)
405
+ + }
406
+ + }
407
+ +
408
+ + async function saveEdit() {
409
+ + if (!state.editingId) return
410
+ +
411
+ + let payload: unknown
412
+ + try {
413
+ + payload = JSON.parse(state.editText)
414
+ + } catch (error) {
415
+ + setState("error", error instanceof Error ? error.message : "Invalid cron patch JSON")
416
+ + return
417
+ + }
418
+ +
419
+ + setState("busyId", state.editingId)
420
+ + setState("error", "")
421
+ + try {
422
+ + await waffleJson(`/api/waffle/cron/jobs/${encodeURIComponent(state.editingId)}`, {
423
+ + method: "PATCH",
424
+ + body: JSON.stringify(payload),
425
+ + })
426
+ + setState("editingId", "")
427
+ + setState("editText", "")
428
+ + await refresh()
429
+ + } catch (error) {
430
+ + setState("error", error instanceof Error ? error.message : "Failed to update cron job")
431
+ + } finally {
432
+ + setState("busyId", "")
433
+ + }
434
+ + }
435
+ +
436
+ + async function runNow(id: string) {
437
+ + setState("busyId", id)
438
+ + try {
439
+ + await waffleJson(`/api/waffle/cron/jobs/${encodeURIComponent(id)}/run`, {
440
+ + method: "POST",
441
+ + })
442
+ + showToast({
443
+ + variant: "success",
444
+ + icon: "circle-check",
445
+ + title: "Cron run queued",
446
+ + })
447
+ + } catch (error) {
448
+ + setState("error", error instanceof Error ? error.message : "Failed to queue cron run")
449
+ + } finally {
450
+ + setState("busyId", "")
451
+ + }
452
+ + }
453
+ +
454
+ + async function remove(id: string) {
455
+ + setState("busyId", id)
456
+ + try {
457
+ + await waffleJson(`/api/waffle/cron/jobs/${encodeURIComponent(id)}`, {
458
+ + method: "DELETE",
459
+ + })
460
+ + await refresh()
461
+ + } catch (error) {
462
+ + setState("error", error instanceof Error ? error.message : "Failed to delete cron job")
463
+ + } finally {
464
+ + setState("busyId", "")
465
+ + }
466
+ + }
467
+ +
468
+ + onMount(() => {
469
+ + void load()
470
+ + })
471
+ +
472
+ + return (
473
+ + <WaffleSettingsPage
474
+ + title="Cron"
475
+ + description="Inspect cron health, create jobs, update jobs with JSON patches, and run them immediately."
476
+ + actions={
477
+ + <WaffleToolbar>
478
+ + <Button variant="ghost" size="large" onClick={() => void refresh()} disabled={state.loading || state.saving}>
479
+ + Refresh
480
+ + </Button>
481
+ + </WaffleToolbar>
482
+ + }
483
+ + >
484
+ + <Show when={state.error}>
485
+ + <WaffleNotice tone="error">{state.error}</WaffleNotice>
486
+ + </Show>
487
+ +
488
+ + <Show when={state.health}>
489
+ + {(health) => (
490
+ + <WaffleSettingsSection title="Cron health">
491
+ + <WaffleMetaRow label="Enabled" value={health().enabled ? "Yes" : "No"} />
492
+ + <WaffleMetaRow label="Total jobs" value={String(health().jobs.total)} />
493
+ + <WaffleMetaRow label="Enabled jobs" value={String(health().jobs.enabled)} />
494
+ + </WaffleSettingsSection>
495
+ + )}
496
+ + </Show>
497
+ +
498
+ + <WaffleSettingsSection title="Registered handlers">
499
+ + <div class="px-4 py-3 text-13-regular text-text-strong break-words">
500
+ + <Show when={state.handlers.length > 0} fallback={<span class="text-text-weak">No handlers registered.</span>}>
501
+ + {state.handlers.join(", ")}
502
+ + </Show>
503
+ + </div>
504
+ + </WaffleSettingsSection>
505
+ +
506
+ + <WaffleSettingsSection title="Jobs">
507
+ + <Show
508
+ + when={state.jobs.length > 0}
509
+ + fallback={<div class="px-4 py-4 text-14-regular text-text-weak">No cron jobs configured.</div>}
510
+ + >
511
+ + <For each={state.jobs}>
512
+ + {(job) => (
513
+ + <div class="px-4 py-4 border-b border-border-weak-base last:border-none flex flex-col gap-3">
514
+ + <div class="flex flex-wrap items-start justify-between gap-3">
515
+ + <div class="flex flex-col gap-1">
516
+ + <div class="text-14-medium text-text-strong">{job.name}</div>
517
+ + <div class="text-12-regular text-text-weak">
518
+ + {job.id} | {job.scheduleKind} | {job.runMode}
519
+ + </div>
520
+ + <div class="text-12-regular text-text-weak">
521
+ + {job.scheduleExpr ?? job.everyMs ?? job.atIso ?? "No schedule"}
522
+ + </div>
523
+ + </div>
524
+ + <div class="flex flex-wrap items-center gap-2">
525
+ + <Button
526
+ + variant="ghost"
527
+ + size="large"
528
+ + onClick={() => {
529
+ + setState("editingId", state.editingId === job.id ? "" : job.id)
530
+ + setState(
531
+ + "editText",
532
+ + state.editingId === job.id
533
+ + ? ""
534
+ + : prettyJson({
535
+ + name: job.name,
536
+ + enabled: job.enabled,
537
+ + scheduleKind: job.scheduleKind,
538
+ + scheduleExpr: job.scheduleExpr,
539
+ + everyMs: job.everyMs,
540
+ + atIso: job.atIso,
541
+ + timezone: job.timezone,
542
+ + runMode: job.runMode,
543
+ + handlerKey: job.handlerKey,
544
+ + conditionModulePath: job.conditionModulePath,
545
+ + conditionDescription: job.conditionDescription,
546
+ + agentPromptTemplate: job.agentPromptTemplate,
547
+ + agentModelOverride: job.agentModelOverride,
548
+ + maxAttempts: job.maxAttempts,
549
+ + retryBackoffMs: job.retryBackoffMs,
550
+ + payload: job.payload,
551
+ + }),
552
+ + )
553
+ + }}
554
+ + >
555
+ + {state.editingId === job.id ? "Close editor" : "Edit JSON"}
556
+ + </Button>
557
+ + <Button
558
+ + variant="ghost"
559
+ + size="large"
560
+ + onClick={() => void runNow(job.id)}
561
+ + disabled={state.busyId === job.id}
562
+ + >
563
+ + Run now
564
+ + </Button>
565
+ + <Button
566
+ + variant="ghost"
567
+ + size="large"
568
+ + onClick={() => void remove(job.id)}
569
+ + disabled={state.busyId === job.id}
570
+ + >
571
+ + Delete
572
+ + </Button>
573
+ + </div>
574
+ + </div>
575
+ +
576
+ + <Show when={state.editingId === job.id}>
577
+ + <div class="flex flex-col gap-3">
578
+ + <WaffleTextArea
579
+ + rows={12}
580
+ + value={state.editText}
581
+ + onInput={(event) => setState("editText", event.currentTarget.value)}
582
+ + />
583
+ + <div class="flex justify-end">
584
+ + <Button size="large" onClick={() => void saveEdit()} disabled={state.busyId === job.id}>
585
+ + Save patch
586
+ + </Button>
587
+ + </div>
588
+ + </div>
589
+ + </Show>
590
+ + </div>
591
+ + )}
592
+ + </For>
593
+ + </Show>
594
+ + </WaffleSettingsSection>
595
+ +
596
+ + <WaffleSettingsSection title="Create job" description="Post a complete cron job definition as JSON.">
597
+ + <WaffleCard
598
+ + footer={
599
+ + <div class="flex justify-end">
600
+ + <Button size="large" onClick={() => void createJob()} disabled={state.saving}>
601
+ + Create job
602
+ + </Button>
603
+ + </div>
604
+ + }
605
+ + >
606
+ + <WaffleTextArea rows={16} value={state.createText} onInput={(event) => setState("createText", event.currentTarget.value)} />
607
+ + </WaffleCard>
608
+ + </WaffleSettingsSection>
609
+ + </WaffleSettingsPage>
610
+ + )
611
+ +}
612
+ diff --git a/packages/app/src/components/settings-mcp.tsx b/packages/app/src/components/settings-mcp.tsx
613
+ new file mode 100644
614
+ index 000000000..88abc0a06
615
+ --- /dev/null
616
+ +++ b/packages/app/src/components/settings-mcp.tsx
617
+ @@ -0,0 +1,203 @@
618
+ +import { Button } from "@opencode-ai/ui/button"
619
+ +import { showToast } from "@opencode-ai/ui/toast"
620
+ +import { For, onMount, Show, type Component } from "solid-js"
621
+ +import { createStore } from "solid-js/store"
622
+ +import { prettyJson, waffleJson } from "@/utils/waffle"
623
+ +import { WaffleCard, WaffleNotice, WaffleSettingsPage, WaffleSettingsSection, WaffleTextArea, WaffleToolbar } from "./settings-waffle-shared"
624
+ +
625
+ +type McpServer =
626
+ + | {
627
+ + id: string
628
+ + type: "remote"
629
+ + enabled: boolean
630
+ + url: string
631
+ + headers?: Record<string, string>
632
+ + oauth?: "auto" | "off"
633
+ + timeoutMs?: number
634
+ + }
635
+ + | {
636
+ + id: string
637
+ + type: "local"
638
+ + enabled: boolean
639
+ + command: string[]
640
+ + environment?: Record<string, string>
641
+ + timeoutMs?: number
642
+ + }
643
+ +
644
+ +type McpStatusMap = Record<
645
+ + string,
646
+ + {
647
+ + status?: "connected" | "disabled" | "failed" | "needs_auth" | "needs_client_registration" | "unknown"
648
+ + error?: string
649
+ + }
650
+ +>
651
+ +
652
+ +type McpPayload = {
653
+ + servers: McpServer[]
654
+ + status: McpStatusMap
655
+ +}
656
+ +
657
+ +export const SettingsMcp: Component = () => {
658
+ + const [state, setState] = createStore({
659
+ + loading: true,
660
+ + saving: false,
661
+ + busyId: "",
662
+ + error: "",
663
+ + payload: null as McpPayload | null,
664
+ + text: "[]",
665
+ + })
666
+ +
667
+ + async function load() {
668
+ + setState("loading", true)
669
+ + setState("error", "")
670
+ + try {
671
+ + const payload = await waffleJson<McpPayload>("/api/waffle/mcp")
672
+ + setState("payload", payload)
673
+ + setState("text", prettyJson(payload.servers))
674
+ + } catch (error) {
675
+ + setState("error", error instanceof Error ? error.message : "Failed to load MCP config")
676
+ + } finally {
677
+ + setState("loading", false)
678
+ + }
679
+ + }
680
+ +
681
+ + async function save() {
682
+ + let parsed: McpServer[]
683
+ + try {
684
+ + parsed = JSON.parse(state.text) as McpServer[]
685
+ + } catch (error) {
686
+ + setState("error", error instanceof Error ? error.message : "Invalid MCP JSON")
687
+ + return
688
+ + }
689
+ +
690
+ + if (!Array.isArray(parsed)) {
691
+ + setState("error", "MCP payload must be a JSON array.")
692
+ + return
693
+ + }
694
+ +
695
+ + setState("saving", true)
696
+ + setState("error", "")
697
+ + try {
698
+ + const payload = await waffleJson<McpPayload>("/api/waffle/mcp", {
699
+ + method: "PUT",
700
+ + body: JSON.stringify({ servers: parsed }),
701
+ + })
702
+ + setState("payload", payload)
703
+ + setState("text", prettyJson(payload.servers))
704
+ + showToast({
705
+ + variant: "success",
706
+ + icon: "circle-check",
707
+ + title: "MCP config saved",
708
+ + })
709
+ + } catch (error) {
710
+ + setState("error", error instanceof Error ? error.message : "Failed to save MCP config")
711
+ + } finally {
712
+ + setState("saving", false)
713
+ + }
714
+ + }
715
+ +
716
+ + async function runAction(id: string, action: "connect" | "disconnect" | "auth/start" | "auth/remove") {
717
+ + setState("busyId", id)
718
+ + try {
719
+ + const payload = await waffleJson<{ status: McpStatusMap; authorizationUrl?: string }>(
720
+ + `/api/waffle/mcp/${encodeURIComponent(id)}/${action}`,
721
+ + {
722
+ + method: "POST",
723
+ + },
724
+ + )
725
+ + if (payload.authorizationUrl) {
726
+ + window.open(payload.authorizationUrl, "_blank", "noopener,noreferrer")
727
+ + }
728
+ + setState("payload", (current) => (current ? { ...current, status: payload.status } : current))
729
+ + } catch (error) {
730
+ + setState("error", error instanceof Error ? error.message : "Failed to update MCP server")
731
+ + } finally {
732
+ + setState("busyId", "")
733
+ + }
734
+ + }
735
+ +
736
+ + onMount(() => {
737
+ + void load()
738
+ + })
739
+ +
740
+ + const statusFor = (id: string) => state.payload?.status?.[id]?.status ?? "unknown"
741
+ +
742
+ + return (
743
+ + <WaffleSettingsPage
744
+ + title="MCP"
745
+ + description="Edit MCP server definitions and drive connection or auth flows through Agent Mockingbird's same-origin APIs."
746
+ + actions={
747
+ + <WaffleToolbar>
748
+ + <Button variant="ghost" size="large" onClick={() => void load()} disabled={state.loading || state.saving}>
749
+ + Refresh
750
+ + </Button>
751
+ + <Button size="large" onClick={() => void save()} disabled={state.loading || state.saving}>
752
+ + Save
753
+ + </Button>
754
+ + </WaffleToolbar>
755
+ + }
756
+ + >
757
+ + <Show when={state.error}>
758
+ + <WaffleNotice tone="error">{state.error}</WaffleNotice>
759
+ + </Show>
760
+ +
761
+ + <WaffleSettingsSection title="Configured servers" description="Connection status and auth actions for configured MCP servers.">
762
+ + <Show
763
+ + when={(state.payload?.servers.length ?? 0) > 0}
764
+ + fallback={<div class="px-4 py-4 text-14-regular text-text-weak">No MCP servers configured.</div>}
765
+ + >
766
+ + <For each={state.payload?.servers ?? []}>
767
+ + {(server) => (
768
+ + <div class="px-4 py-3 border-b border-border-weak-base last:border-none flex flex-wrap items-center justify-between gap-4">
769
+ + <div class="flex flex-col gap-1 min-w-0">
770
+ + <div class="text-14-medium text-text-strong">{server.id}</div>
771
+ + <div class="text-12-regular text-text-weak">
772
+ + {server.type === "remote" ? server.url : server.command.join(" ")}
773
+ + </div>
774
+ + <div class="text-12-regular text-text-weak">Status: {statusFor(server.id)}</div>
775
+ + </div>
776
+ + <div class="flex flex-wrap items-center gap-2">
777
+ + <Button
778
+ + variant="ghost"
779
+ + size="large"
780
+ + disabled={state.busyId === server.id}
781
+ + onClick={() => void runAction(server.id, statusFor(server.id) === "connected" ? "disconnect" : "connect")}
782
+ + >
783
+ + {statusFor(server.id) === "connected" ? "Disconnect" : "Connect"}
784
+ + </Button>
785
+ + <Button
786
+ + variant="ghost"
787
+ + size="large"
788
+ + disabled={state.busyId === server.id}
789
+ + onClick={() => void runAction(server.id, "auth/start")}
790
+ + >
791
+ + Start auth
792
+ + </Button>
793
+ + <Button
794
+ + variant="ghost"
795
+ + size="large"
796
+ + disabled={state.busyId === server.id}
797
+ + onClick={() => void runAction(server.id, "auth/remove")}
798
+ + >
799
+ + Remove auth
800
+ + </Button>
801
+ + </div>
802
+ + </div>
803
+ + )}
804
+ + </For>
805
+ + </Show>
806
+ + </WaffleSettingsSection>
807
+ +
808
+ + <WaffleSettingsSection title="Server editor" description="Edit the full MCP server array as JSON.">
809
+ + <WaffleCard>
810
+ + <WaffleTextArea
811
+ + rows={22}
812
+ + value={state.text}
813
+ + onInput={(event) => setState("text", event.currentTarget.value)}
814
+ + spellcheck={false}
815
+ + />
816
+ + </WaffleCard>
817
+ + </WaffleSettingsSection>
818
+ + </WaffleSettingsPage>
819
+ + )
820
+ +}
821
+ diff --git a/packages/app/src/components/settings-runtime.tsx b/packages/app/src/components/settings-runtime.tsx
822
+ new file mode 100644
823
+ index 000000000..fd091697e
824
+ --- /dev/null
825
+ +++ b/packages/app/src/components/settings-runtime.tsx
826
+ @@ -0,0 +1,120 @@
827
+ +import { Button } from "@opencode-ai/ui/button"
828
+ +import { showToast } from "@opencode-ai/ui/toast"
829
+ +import { onMount, Show, type Component } from "solid-js"
830
+ +import { createStore } from "solid-js/store"
831
+ +import { prettyJson, waffleJson } from "@/utils/waffle"
832
+ +import { WaffleCard, WaffleMetaRow, WaffleNotice, WaffleSettingsPage, WaffleSettingsSection, WaffleTextArea, WaffleToolbar } from "./settings-waffle-shared"
833
+ +
834
+ +type RuntimePayload = {
835
+ + hash: string
836
+ + path: string
837
+ + config: {
838
+ + workspace: {
839
+ + pinnedDirectory: string
840
+ + }
841
+ + runtime: Record<string, unknown>
842
+ + }
843
+ +}
844
+ +
845
+ +export const SettingsRuntime: Component = () => {
846
+ + const [state, setState] = createStore({
847
+ + loading: true,
848
+ + saving: false,
849
+ + error: "",
850
+ + payload: null as RuntimePayload | null,
851
+ + text: "{\n \"workspace\": {\n \"pinnedDirectory\": \"\"\n },\n \"runtime\": {}\n}",
852
+ + })
853
+ +
854
+ + async function load() {
855
+ + setState("loading", true)
856
+ + setState("error", "")
857
+ + try {
858
+ + const payload = await waffleJson<RuntimePayload>("/api/waffle/runtime/config")
859
+ + setState("payload", payload)
860
+ + setState("text", prettyJson(payload.config))
861
+ + } catch (error) {
862
+ + setState("error", error instanceof Error ? error.message : "Failed to load runtime config")
863
+ + } finally {
864
+ + setState("loading", false)
865
+ + }
866
+ + }
867
+ +
868
+ + async function save() {
869
+ + let parsed: unknown
870
+ + try {
871
+ + parsed = JSON.parse(state.text)
872
+ + } catch (error) {
873
+ + setState("error", error instanceof Error ? error.message : "Invalid JSON")
874
+ + return
875
+ + }
876
+ +
877
+ + setState("saving", true)
878
+ + setState("error", "")
879
+ + try {
880
+ + const payload = await waffleJson<RuntimePayload>("/api/waffle/runtime/config/replace", {
881
+ + method: "POST",
882
+ + body: JSON.stringify({
883
+ + config: parsed,
884
+ + expectedHash: state.payload?.hash,
885
+ + }),
886
+ + })
887
+ + setState("payload", payload)
888
+ + setState("text", prettyJson(payload.config))
889
+ + showToast({
890
+ + variant: "success",
891
+ + icon: "circle-check",
892
+ + title: "Runtime config saved",
893
+ + })
894
+ + } catch (error) {
895
+ + setState("error", error instanceof Error ? error.message : "Failed to save runtime config")
896
+ + } finally {
897
+ + setState("saving", false)
898
+ + }
899
+ + }
900
+ +
901
+ + onMount(() => {
902
+ + void load()
903
+ + })
904
+ +
905
+ + return (
906
+ + <WaffleSettingsPage
907
+ + title="Runtime"
908
+ + description="Edit Agent Mockingbird runtime settings owned outside OpenCode's workspace config."
909
+ + actions={
910
+ + <WaffleToolbar>
911
+ + <Button variant="ghost" size="large" onClick={() => void load()} disabled={state.loading || state.saving}>
912
+ + Refresh
913
+ + </Button>
914
+ + <Button size="large" onClick={() => void save()} disabled={state.loading || state.saving}>
915
+ + Save
916
+ + </Button>
917
+ + </WaffleToolbar>
918
+ + }
919
+ + >
920
+ + <Show when={state.error}>
921
+ + <WaffleNotice tone="error">{state.error}</WaffleNotice>
922
+ + </Show>
923
+ +
924
+ + <Show when={state.payload}>
925
+ + {(payload) => (
926
+ + <WaffleSettingsSection title="Config metadata">
927
+ + <WaffleMetaRow label="Config file" value={payload().path} />
928
+ + <WaffleMetaRow label="Pinned workspace" value={payload().config.workspace.pinnedDirectory} />
929
+ + <WaffleMetaRow label="Revision hash" value={payload().hash} />
930
+ + </WaffleSettingsSection>
931
+ + )}
932
+ + </Show>
933
+ +
934
+ + <WaffleSettingsSection title="Config editor" description="Replace the current Agent Mockingbird config payload with JSON.">
935
+ + <WaffleCard>
936
+ + <WaffleTextArea
937
+ + rows={22}
938
+ + value={state.text}
939
+ + onInput={(event) => setState("text", event.currentTarget.value)}
940
+ + spellcheck={false}
941
+ + />
942
+ + </WaffleCard>
943
+ + </WaffleSettingsSection>
944
+ + </WaffleSettingsPage>
945
+ + )
946
+ +}
947
+ diff --git a/packages/app/src/components/settings-skills.tsx b/packages/app/src/components/settings-skills.tsx
948
+ new file mode 100644
949
+ index 000000000..2254d384c
950
+ --- /dev/null
951
+ +++ b/packages/app/src/components/settings-skills.tsx
952
+ @@ -0,0 +1,256 @@
953
+ +import { Button } from "@opencode-ai/ui/button"
954
+ +import { Switch } from "@opencode-ai/ui/switch"
955
+ +import { showToast } from "@opencode-ai/ui/toast"
956
+ +import { createSignal, For, onMount, Show, type Component } from "solid-js"
957
+ +import { createStore } from "solid-js/store"
958
+ +import { WaffleCard, WaffleInput, WaffleMetaRow, WaffleNotice, WaffleSettingsPage, WaffleSettingsSection, WaffleTextArea, WaffleToolbar } from "./settings-waffle-shared"
959
+ +import { waffleJson } from "@/utils/waffle"
960
+ +
961
+ +type SkillEntry = {
962
+ + id: string
963
+ + name: string
964
+ + description: string
965
+ + location: string
966
+ + enabled: boolean
967
+ + managed: boolean
968
+ +}
969
+ +
970
+ +type SkillsPayload = {
971
+ + skills: SkillEntry[]
972
+ + enabled: string[]
973
+ + disabled: string[]
974
+ + invalid: Array<{
975
+ + id?: string
976
+ + location: string
977
+ + reason: string
978
+ + }>
979
+ + hash: string
980
+ + revision: string
981
+ + managedPath: string
982
+ + disabledPath: string
983
+ +}
984
+ +
985
+ +export const SettingsSkills: Component = () => {
986
+ + const [state, setState] = createStore({
987
+ + loading: true,
988
+ + busyId: "",
989
+ + error: "",
990
+ + catalog: null as SkillsPayload | null,
991
+ + importId: "",
992
+ + importEnabled: true,
993
+ + importContent:
994
+ + "---\nname: example-skill\ndescription: Example managed skill\n---\n\nAdd instructions here.\n",
995
+ + })
996
+ + const [refreshTick, setRefreshTick] = createSignal(0)
997
+ +
998
+ + async function load() {
999
+ + refreshTick()
1000
+ + setState("loading", true)
1001
+ + setState("error", "")
1002
+ + try {
1003
+ + const catalog = await waffleJson<SkillsPayload>("/api/waffle/skills")
1004
+ + setState("catalog", catalog)
1005
+ + } catch (error) {
1006
+ + const message = error instanceof Error ? error.message : "Failed to load skills"
1007
+ + setState("error", message)
1008
+ + } finally {
1009
+ + setState("loading", false)
1010
+ + }
1011
+ + }
1012
+ +
1013
+ + async function refresh() {
1014
+ + setRefreshTick((value) => value + 1)
1015
+ + await load()
1016
+ + }
1017
+ +
1018
+ + async function toggleSkill(id: string, enabled: boolean) {
1019
+ + setState("busyId", id)
1020
+ + try {
1021
+ + const next = await waffleJson<SkillsPayload>(`/api/waffle/skills/${encodeURIComponent(id)}`, {
1022
+ + method: "PATCH",
1023
+ + body: JSON.stringify({ enabled }),
1024
+ + })
1025
+ + setState("catalog", next)
1026
+ + } catch (error) {
1027
+ + showToast({
1028
+ + title: "Failed to update skill",
1029
+ + description: error instanceof Error ? error.message : String(error),
1030
+ + })
1031
+ + } finally {
1032
+ + setState("busyId", "")
1033
+ + }
1034
+ + }
1035
+ +
1036
+ + async function removeSkill(id: string) {
1037
+ + setState("busyId", id)
1038
+ + try {
1039
+ + const next = await waffleJson<SkillsPayload>(`/api/waffle/skills/${encodeURIComponent(id)}`, {
1040
+ + method: "DELETE",
1041
+ + })
1042
+ + setState("catalog", next)
1043
+ + } catch (error) {
1044
+ + showToast({
1045
+ + title: "Failed to remove skill",
1046
+ + description: error instanceof Error ? error.message : String(error),
1047
+ + })
1048
+ + } finally {
1049
+ + setState("busyId", "")
1050
+ + }
1051
+ + }
1052
+ +
1053
+ + async function importSkill() {
1054
+ + if (!state.importId.trim() || !state.importContent.trim()) {
1055
+ + setState("error", "Skill id and SKILL.md content are required.")
1056
+ + return
1057
+ + }
1058
+ +
1059
+ + setState("error", "")
1060
+ + setState("busyId", "import")
1061
+ + try {
1062
+ + await waffleJson("/api/waffle/skills/import", {
1063
+ + method: "POST",
1064
+ + body: JSON.stringify({
1065
+ + id: state.importId,
1066
+ + content: state.importContent,
1067
+ + enable: state.importEnabled,
1068
+ + expectedHash: state.catalog?.hash,
1069
+ + }),
1070
+ + })
1071
+ + showToast({
1072
+ + variant: "success",
1073
+ + icon: "circle-check",
1074
+ + title: "Skill imported",
1075
+ + })
1076
+ + setState("importId", "")
1077
+ + await refresh()
1078
+ + } catch (error) {
1079
+ + setState("error", error instanceof Error ? error.message : "Failed to import skill")
1080
+ + } finally {
1081
+ + setState("busyId", "")
1082
+ + }
1083
+ + }
1084
+ +
1085
+ + onMount(() => {
1086
+ + void load()
1087
+ + })
1088
+ +
1089
+ + return (
1090
+ + <WaffleSettingsPage
1091
+ + title="Skills"
1092
+ + description="Manage the workspace skill catalog and import managed skills for the pinned workspace."
1093
+ + actions={
1094
+ + <WaffleToolbar>
1095
+ + <Button variant="ghost" size="large" onClick={() => void refresh()} disabled={state.loading}>
1096
+ + Refresh
1097
+ + </Button>
1098
+ + </WaffleToolbar>
1099
+ + }
1100
+ + >
1101
+ + <Show when={state.error}>
1102
+ + <WaffleNotice tone="error">{state.error}</WaffleNotice>
1103
+ + </Show>
1104
+ +
1105
+ + <Show when={state.catalog}>
1106
+ + {(catalog) => (
1107
+ + <>
1108
+ + <WaffleSettingsSection title="Catalog paths">
1109
+ + <WaffleMetaRow label="Managed skills path" value={catalog().managedPath} />
1110
+ + <WaffleMetaRow label="Disabled skills path" value={catalog().disabledPath} />
1111
+ + </WaffleSettingsSection>
1112
+ +
1113
+ + <WaffleSettingsSection title="Installed skills" description="Enable, disable, or remove managed skills.">
1114
+ + <Show
1115
+ + when={catalog().skills.length > 0}
1116
+ + fallback={<div class="px-4 py-4 text-14-regular text-text-weak">No managed skills found.</div>}
1117
+ + >
1118
+ + <For each={catalog().skills}>
1119
+ + {(skill) => (
1120
+ + <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
1121
+ + <div class="min-w-0 flex flex-col gap-1">
1122
+ + <div class="text-14-medium text-text-strong">{skill.name}</div>
1123
+ + <div class="text-12-regular text-text-weak">{skill.description}</div>
1124
+ + <div class="text-11-regular text-text-weak break-all">{skill.location}</div>
1125
+ + </div>
1126
+ + <div class="flex items-center gap-2">
1127
+ + <Switch
1128
+ + checked={skill.enabled}
1129
+ + disabled={state.busyId === skill.id}
1130
+ + onChange={(enabled) => void toggleSkill(skill.id, enabled)}
1131
+ + hideLabel
1132
+ + >
1133
+ + {skill.name}
1134
+ + </Switch>
1135
+ + <Button
1136
+ + variant="ghost"
1137
+ + size="large"
1138
+ + disabled={state.busyId === skill.id}
1139
+ + onClick={() => void removeSkill(skill.id)}
1140
+ + >
1141
+ + Remove
1142
+ + </Button>
1143
+ + </div>
1144
+ + </div>
1145
+ + )}
1146
+ + </For>
1147
+ + </Show>
1148
+ + </WaffleSettingsSection>
1149
+ +
1150
+ + <Show when={catalog().invalid.length > 0}>
1151
+ + <WaffleSettingsSection title="Invalid skills">
1152
+ + <For each={catalog().invalid}>
1153
+ + {(issue) => (
1154
+ + <div class="px-4 py-3 border-b border-border-weak-base last:border-none">
1155
+ + <div class="text-13-medium text-text-strong">{issue.id ?? "unknown-skill"}</div>
1156
+ + <div class="text-12-regular text-text-weak">{issue.reason}</div>
1157
+ + <div class="text-11-regular text-text-weak break-all">{issue.location}</div>
1158
+ + </div>
1159
+ + )}
1160
+ + </For>
1161
+ + </WaffleSettingsSection>
1162
+ + </Show>
1163
+ + </>
1164
+ + )}
1165
+ + </Show>
1166
+ +
1167
+ + <WaffleSettingsSection
1168
+ + title="Import skill"
1169
+ + description="Paste a managed SKILL.md and import it into the pinned workspace."
1170
+ + >
1171
+ + <WaffleCard
1172
+ + footer={
1173
+ + <div class="flex flex-wrap items-center justify-between gap-3">
1174
+ + <label class="flex items-center gap-2 text-13-regular text-text-strong">
1175
+ + <input
1176
+ + type="checkbox"
1177
+ + checked={state.importEnabled}
1178
+ + onChange={(event) => setState("importEnabled", event.currentTarget.checked)}
1179
+ + />
1180
+ + Enable after import
1181
+ + </label>
1182
+ + <Button size="large" onClick={() => void importSkill()} disabled={state.busyId === "import"}>
1183
+ + Import skill
1184
+ + </Button>
1185
+ + </div>
1186
+ + }
1187
+ + >
1188
+ + <div class="flex flex-col gap-2">
1189
+ + <span class="text-12-medium text-text-weak">Skill id</span>
1190
+ + <WaffleInput
1191
+ + value={state.importId}
1192
+ + placeholder="example-skill"
1193
+ + onInput={(event) => setState("importId", event.currentTarget.value)}
1194
+ + />
1195
+ + </div>
1196
+ + <div class="flex flex-col gap-2">
1197
+ + <span class="text-12-medium text-text-weak">SKILL.md content</span>
1198
+ + <WaffleTextArea
1199
+ + rows={14}
1200
+ + value={state.importContent}
1201
+ + onInput={(event) => setState("importContent", event.currentTarget.value)}
1202
+ + />
1203
+ + </div>
1204
+ + </WaffleCard>
1205
+ + </WaffleSettingsSection>
1206
+ + </WaffleSettingsPage>
1207
+ + )
1208
+ +}
1209
+ diff --git a/packages/app/src/components/settings-waffle-shared.tsx b/packages/app/src/components/settings-waffle-shared.tsx
1210
+ new file mode 100644
1211
+ index 000000000..64ece4b04
1212
+ --- /dev/null
1213
+ +++ b/packages/app/src/components/settings-waffle-shared.tsx
1214
+ @@ -0,0 +1,116 @@
1215
+ +import { type JSX, type ParentProps, Show } from "solid-js"
1216
+ +
1217
+ +export function WaffleSettingsPage(
1218
+ + props: ParentProps<{
1219
+ + title: string
1220
+ + description?: string
1221
+ + actions?: JSX.Element
1222
+ + }>,
1223
+ +) {
1224
+ + return (
1225
+ + <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
1226
+ + <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
1227
+ + <div class="flex flex-wrap items-start justify-between gap-4 pt-6 pb-6 max-w-[760px]">
1228
+ + <div class="flex flex-col gap-1 min-w-0">
1229
+ + <h2 class="text-16-medium text-text-strong">{props.title}</h2>
1230
+ + <Show when={props.description}>
1231
+ + <p class="text-14-regular text-text-weak max-w-[640px]">{props.description}</p>
1232
+ + </Show>
1233
+ + </div>
1234
+ + <Show when={props.actions}>
1235
+ + <div class="flex items-center gap-2">{props.actions}</div>
1236
+ + </Show>
1237
+ + </div>
1238
+ + </div>
1239
+ +
1240
+ + <div class="flex flex-col gap-8 max-w-[760px]">{props.children}</div>
1241
+ + </div>
1242
+ + )
1243
+ +}
1244
+ +
1245
+ +export function WaffleSettingsSection(
1246
+ + props: ParentProps<{
1247
+ + title: string
1248
+ + description?: string
1249
+ + actions?: JSX.Element
1250
+ + }>,
1251
+ +) {
1252
+ + return (
1253
+ + <section class="flex flex-col gap-3">
1254
+ + <div class="flex flex-wrap items-start justify-between gap-3">
1255
+ + <div class="flex flex-col gap-1">
1256
+ + <h3 class="text-14-medium text-text-strong">{props.title}</h3>
1257
+ + <Show when={props.description}>
1258
+ + <p class="text-12-regular text-text-weak">{props.description}</p>
1259
+ + </Show>
1260
+ + </div>
1261
+ + <Show when={props.actions}>
1262
+ + <div class="flex items-center gap-2">{props.actions}</div>
1263
+ + </Show>
1264
+ + </div>
1265
+ + <div class="bg-surface-raised-base rounded-lg border border-border-weak-base">{props.children}</div>
1266
+ + </section>
1267
+ + )
1268
+ +}
1269
+ +
1270
+ +export function WaffleMetaRow(props: { label: string; value: string | JSX.Element }) {
1271
+ + return (
1272
+ + <div class="flex flex-col gap-1 px-4 py-3 border-b border-border-weak-base last:border-none">
1273
+ + <span class="text-12-medium text-text-weak">{props.label}</span>
1274
+ + <div class="text-13-regular text-text-strong break-all">{props.value}</div>
1275
+ + </div>
1276
+ + )
1277
+ +}
1278
+ +
1279
+ +export function WaffleToolbar(props: ParentProps) {
1280
+ + return <div class="flex flex-wrap items-center gap-2">{props.children}</div>
1281
+ +}
1282
+ +
1283
+ +export function WaffleNotice(props: { tone?: "error" | "success" | "info"; children: JSX.Element }) {
1284
+ + const tone = () => props.tone ?? "info"
1285
+ + return (
1286
+ + <div
1287
+ + classList={{
1288
+ + "rounded-lg px-3 py-2 text-13-regular border": true,
1289
+ + "bg-surface-base text-text-base border-border-weak-base": tone() === "info",
1290
+ + "bg-background-base text-text-strong border-border-strong-base": tone() === "success",
1291
+ + "bg-icon-critical-base/8 text-text-strong border-icon-critical-base/20": tone() === "error",
1292
+ + }}
1293
+ + >
1294
+ + {props.children}
1295
+ + </div>
1296
+ + )
1297
+ +}
1298
+ +
1299
+ +export function WaffleInput(props: JSX.InputHTMLAttributes<HTMLInputElement>) {
1300
+ + return (
1301
+ + <input
1302
+ + {...props}
1303
+ + class={`w-full rounded-lg border border-border-weak-base bg-surface-base px-3 py-2 text-13-regular text-text-strong outline-none placeholder:text-text-weak focus:border-border-strong-base ${
1304
+ + props.class ?? ""
1305
+ + }`}
1306
+ + />
1307
+ + )
1308
+ +}
1309
+ +
1310
+ +export function WaffleTextArea(props: JSX.TextareaHTMLAttributes<HTMLTextAreaElement>) {
1311
+ + return (
1312
+ + <textarea
1313
+ + {...props}
1314
+ + class={`w-full rounded-lg border border-border-weak-base bg-surface-base px-3 py-2 text-12-mono text-text-strong outline-none placeholder:text-text-weak focus:border-border-strong-base ${
1315
+ + props.class ?? ""
1316
+ + }`}
1317
+ + />
1318
+ + )
1319
+ +}
1320
+ +
1321
+ +export function WaffleCard(props: ParentProps<{ footer?: JSX.Element }>) {
1322
+ + return (
1323
+ + <div class="rounded-lg border border-border-weak-base bg-background-base overflow-hidden">
1324
+ + <div class="p-4 flex flex-col gap-4">{props.children}</div>
1325
+ + <Show when={props.footer}>
1326
+ + <div class="px-4 py-3 border-t border-border-weak-base bg-surface-base">{props.footer}</div>
1327
+ + </Show>
1328
+ + </div>
1329
+ + )
1330
+ +}
1331
+ diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
1332
+ index 61facb84e..30f360fb9 100644
1333
+ --- a/packages/app/src/components/status-popover.tsx
1334
+ +++ b/packages/app/src/components/status-popover.tsx
1335
+ @@ -1,11 +1,9 @@
1336
+ import { Button } from "@opencode-ai/ui/button"
1337
+ -import { useDialog } from "@opencode-ai/ui/context/dialog"
1338
+ import { Icon } from "@opencode-ai/ui/icon"
1339
+ import { Popover } from "@opencode-ai/ui/popover"
1340
+ import { Switch } from "@opencode-ai/ui/switch"
1341
+ import { Tabs } from "@opencode-ai/ui/tabs"
1342
+ import { showToast } from "@opencode-ai/ui/toast"
1343
+ -import { useNavigate } from "@solidjs/router"
1344
+ import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
1345
+ import { createStore, reconcile } from "solid-js/store"
1346
+ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
1347
+ @@ -167,9 +165,7 @@ export function StatusPopover() {
1348
+ const sdk = useSDK()
1349
+ const server = useServer()
1350
+ const platform = usePlatform()
1351
+ - const dialog = useDialog()
1352
+ const language = useLanguage()
1353
+ - const navigate = useNavigate()
1354
+
1355
+ const [shown, setShown] = createSignal(false)
1356
+ const servers = createMemo(() => {
1357
+ @@ -236,13 +232,12 @@ export function StatusPopover() {
1358
+ aria-label={language.t("status.popover.ariaLabel")}
1359
+ class="tabs bg-background-strong rounded-xl overflow-hidden"
1360
+ data-component="tabs"
1361
+ - data-active="servers"
1362
+ - defaultValue="servers"
1363
+ + data-active="server"
1364
+ + defaultValue="server"
1365
+ variant="alt"
1366
+ >
1367
+ <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
1368
+ - <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
1369
+ - {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
1370
+ + <Tabs.Trigger value="server" data-slot="tab" class="text-12-regular">
1371
+ {language.t("status.popover.tab.servers")}
1372
+ </Tabs.Trigger>
1373
+ <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
1374
+ @@ -259,61 +254,32 @@ export function StatusPopover() {
1375
+ </Tabs.Trigger>
1376
+ </Tabs.List>
1377
+
1378
+ - <Tabs.Content value="servers">
1379
+ + <Tabs.Content value="server">
1380
+ <div class="flex flex-col px-2 pb-2">
1381
+ <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
1382
+ <For each={sortedServers()}>
1383
+ {(s) => {
1384
+ const key = ServerConnection.key(s)
1385
+ - const isBlocked = () => health[key]?.healthy === false
1386
+ return (
1387
+ - <button
1388
+ - type="button"
1389
+ - class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
1390
+ - classList={{
1391
+ - "hover:bg-surface-raised-base-hover": !isBlocked(),
1392
+ - "cursor-not-allowed": isBlocked(),
1393
+ - }}
1394
+ - aria-disabled={isBlocked()}
1395
+ - onClick={() => {
1396
+ - if (isBlocked()) return
1397
+ - server.setActive(key)
1398
+ - navigate("/")
1399
+ - }}
1400
+ - >
1401
+ + <div class="flex items-center gap-2 w-full pl-3 pr-1.5 py-2 rounded-md">
1402
+ <ServerHealthIndicator health={health[key]} />
1403
+ <ServerRow
1404
+ conn={s}
1405
+ - dimmed={isBlocked()}
1406
+ + dimmed={health[key]?.healthy === false}
1407
+ status={health[key]}
1408
+ class="flex items-center gap-2 w-full min-w-0"
1409
+ nameClass="text-14-regular text-text-base truncate"
1410
+ versionClass="text-12-regular text-text-weak truncate"
1411
+ - badge={
1412
+ - <Show when={key === defaultServer.key()}>
1413
+ - <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
1414
+ - {language.t("common.default")}
1415
+ - </span>
1416
+ - </Show>
1417
+ - }
1418
+ >
1419
+ <div class="flex-1" />
1420
+ <Show when={server.current && key === ServerConnection.key(server.current)}>
1421
+ <Icon name="check" size="small" class="text-icon-weak shrink-0" />
1422
+ </Show>
1423
+ </ServerRow>
1424
+ - </button>
1425
+ + </div>
1426
+ )
1427
+ }}
1428
+ </For>
1429
+ -
1430
+ - <Button
1431
+ - variant="secondary"
1432
+ - class="mt-3 self-start h-8 px-3 py-1.5"
1433
+ - onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
1434
+ - >
1435
+ - {language.t("status.popover.action.manageServers")}
1436
+ - </Button>
1437
+ </div>
1438
+ </div>
1439
+ </Tabs.Content>
1440
+ diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
1441
+ index 1171ca905..5126d03a2 100644
1442
+ --- a/packages/app/src/context/server.tsx
1443
+ +++ b/packages/app/src/context/server.tsx
1444
+ @@ -94,7 +94,11 @@ export namespace ServerConnection {
1445
+
1446
+ export const { use: useServer, provider: ServerProvider } = createSimpleContext({
1447
+ name: "Server",
1448
+ - init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
1449
+ + init: (props: {
1450
+ + defaultServer: ServerConnection.Key
1451
+ + servers?: Array<ServerConnection.Any>
1452
+ + locked?: boolean
1453
+ + }) => {
1454
+ const checkServerHealth = useCheckServerHealth()
1455
+
1456
+ const [store, setStore, _, ready] = persisted(
1457
+ @@ -109,6 +113,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
1458
+ const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
1459
+
1460
+ const allServers = createMemo((): Array<ServerConnection.Any> => {
1461
+ + if (props.locked) return props.servers ?? []
1462
+ const servers = [
1463
+ ...(props.servers ?? []),
1464
+ ...store.list.map((value) =>
1465
+ @@ -164,10 +169,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
1466
+ }
1467
+
1468
+ function setActive(input: ServerConnection.Key) {
1469
+ + if (!allServers().some((server) => ServerConnection.key(server) === input)) return
1470
+ if (state.active !== input) setState("active", input)
1471
+ }
1472
+
1473
+ function add(input: ServerConnection.Http) {
1474
+ + if (props.locked) return
1475
+ const url_ = normalizeServerUrl(input.http.url)
1476
+ if (!url_) return
1477
+ const conn = { ...input, http: { ...input.http, url: url_ } }
1478
+ @@ -184,6 +191,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
1479
+ }
1480
+
1481
+ function remove(key: ServerConnection.Key) {
1482
+ + if (props.locked) return
1483
+ const list = store.list.filter((x) => url(x) !== key)
1484
+ batch(() => {
1485
+ setStore("list", list)
1486
+ @@ -198,6 +206,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
1487
+
1488
+ const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
1489
+
1490
+ + createEffect(() => {
1491
+ + const available = allServers()
1492
+ + if (available.length === 0) return
1493
+ + if (available.some((server) => ServerConnection.key(server) === state.active)) return
1494
+ + setState("active", ServerConnection.key(available[0]!))
1495
+ + })
1496
+ +
1497
+ createEffect(() => {
1498
+ const current_ = current()
1499
+ if (!current_) return
1500
+ diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
1501
+ index b5cbed6e7..2198a7bc1 100644
1502
+ --- a/packages/app/src/entry.tsx
1503
+ +++ b/packages/app/src/entry.tsx
1504
+ @@ -9,8 +9,6 @@ import { handleNotificationClick } from "@/utils/notification-click"
1505
+ import pkg from "../package.json"
1506
+ import { ServerConnection } from "./context/server"
1507
+
1508
+ -const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
1509
+ -
1510
+ const getLocale = () => {
1511
+ if (typeof navigator !== "object") return "en" as const
1512
+ const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
1513
+ @@ -27,31 +25,6 @@ const getRootNotFoundError = () => {
1514
+ return locale === "zh" ? (zh[key] ?? en[key]) : en[key]
1515
+ }
1516
+
1517
+ -const getStorage = (key: string) => {
1518
+ - if (typeof localStorage === "undefined") return null
1519
+ - try {
1520
+ - return localStorage.getItem(key)
1521
+ - } catch {
1522
+ - return null
1523
+ - }
1524
+ -}
1525
+ -
1526
+ -const setStorage = (key: string, value: string | null) => {
1527
+ - if (typeof localStorage === "undefined") return
1528
+ - try {
1529
+ - if (value !== null) {
1530
+ - localStorage.setItem(key, value)
1531
+ - return
1532
+ - }
1533
+ - localStorage.removeItem(key)
1534
+ - } catch {
1535
+ - return
1536
+ - }
1537
+ -}
1538
+ -
1539
+ -const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
1540
+ -const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
1541
+ -
1542
+ const notify: Platform["notify"] = async (title, description, href) => {
1543
+ if (!("Notification" in window)) return
1544
+
1545
+ @@ -124,7 +97,6 @@ const platform: Platform = {
1546
+ },
1547
+ setDefaultServer: writeDefaultServerUrl,
1548
+ }
1549
+ -
1550
+ if (root instanceof HTMLElement) {
1551
+ const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
1552
+ render(
1553
+ @@ -132,8 +104,9 @@ if (root instanceof HTMLElement) {
1554
+ <PlatformProvider value={platform}>
1555
+ <AppBaseProviders>
1556
+ <AppInterface
1557
+ - defaultServer={ServerConnection.Key.make(getDefaultUrl())}
1558
+ + defaultServer={ServerConnection.key(server)}
1559
+ servers={[server]}
1560
+ + lockServerSelection
1561
+ disableHealthCheck
1562
+ />
1563
+ </AppBaseProviders>
1564
+ diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
1565
+ index ad12e1e0d..0bb31bab0 100644
1566
+ --- a/packages/app/src/i18n/en.ts
1567
+ +++ b/packages/app/src/i18n/en.ts
1568
+ @@ -703,6 +703,7 @@ export const dict = {
1569
+
1570
+ "settings.section.desktop": "Desktop",
1571
+ "settings.section.server": "Server",
1572
+ + "settings.section.wafflebot": "Wafflebot",
1573
+ "settings.tab.general": "General",
1574
+ "settings.tab.shortcuts": "Shortcuts",
1575
+ "settings.desktop.section.wsl": "WSL",
1576
+ @@ -862,11 +863,14 @@ export const dict = {
1577
+ "settings.models.title": "Models",
1578
+ "settings.models.description": "Model settings will be configurable here.",
1579
+ "settings.agents.title": "Agents",
1580
+ - "settings.agents.description": "Agent settings will be configurable here.",
1581
+ + "settings.agents.description": "Edit pinned-workspace agent definitions.",
1582
+ "settings.commands.title": "Commands",
1583
+ "settings.commands.description": "Command settings will be configurable here.",
1584
+ "settings.mcp.title": "MCP",
1585
+ - "settings.mcp.description": "MCP settings will be configurable here.",
1586
+ + "settings.mcp.description": "Configure pinned-workspace MCP servers.",
1587
+ + "settings.skills.title": "Skills",
1588
+ + "settings.runtime.title": "Runtime",
1589
+ + "settings.cron.title": "Cron",
1590
+
1591
+ "settings.permissions.title": "Permissions",
1592
+ "settings.permissions.description": "Control what tools the server can use by default.",
1593
+ diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
1594
+ index f993ffcd8..50b6fa5ad 100644
1595
+ --- a/packages/app/src/pages/directory-layout.tsx
1596
+ +++ b/packages/app/src/pages/directory-layout.tsx
1597
+ @@ -1,4 +1,4 @@
1598
+ -import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
1599
+ +import { batch, createEffect, createMemo, createResource, Show, type ParentProps } from "solid-js"
1600
+ import { createStore } from "solid-js/store"
1601
+ import { useLocation, useNavigate, useParams } from "@solidjs/router"
1602
+ import { SDKProvider } from "@/context/sdk"
1603
+ @@ -11,6 +11,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
1604
+ import { decode64 } from "@/utils/base64"
1605
+ import { showToast } from "@opencode-ai/ui/toast"
1606
+ import { useLanguage } from "@/context/language"
1607
+ +import { readPinnedWorkspace, workspaceHref } from "@/utils/waffle"
1608
+ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
1609
+ const navigate = useNavigate()
1610
+ const sync = useSync()
1611
+ @@ -35,8 +36,16 @@ export default function Layout(props: ParentProps) {
1612
+ const language = useLanguage()
1613
+ const globalSDK = useGlobalSDK()
1614
+ const directory = createMemo(() => decode64(params.dir) ?? "")
1615
+ + const [pinnedWorkspace] = createResource(readPinnedWorkspace)
1616
+ const [state, setState] = createStore({ invalid: "", resolved: "" })
1617
+
1618
+ + createEffect(() => {
1619
+ + const current = directory()
1620
+ + const pinned = pinnedWorkspace()?.directory?.trim()
1621
+ + if (!current || !pinned || current === pinned) return
1622
+ + navigate(workspaceHref(pinned), { replace: true })
1623
+ + })
1624
+ +
1625
+ createEffect(() => {
1626
+ if (!params.dir) return
1627
+ const raw = directory()
1628
+ diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
1629
+ index ba3a2b942..ab5c84329 100644
1630
+ --- a/packages/app/src/pages/home.tsx
1631
+ +++ b/packages/app/src/pages/home.tsx
1632
+ @@ -1,131 +1,33 @@
1633
+ -import { createMemo, For, Match, Switch } from "solid-js"
1634
+ -import { Button } from "@opencode-ai/ui/button"
1635
+ -import { Logo } from "@opencode-ai/ui/logo"
1636
+ -import { useLayout } from "@/context/layout"
1637
+ import { useNavigate } from "@solidjs/router"
1638
+ -import { base64Encode } from "@opencode-ai/util/encode"
1639
+ -import { Icon } from "@opencode-ai/ui/icon"
1640
+ -import { usePlatform } from "@/context/platform"
1641
+ -import { DateTime } from "luxon"
1642
+ -import { useDialog } from "@opencode-ai/ui/context/dialog"
1643
+ -import { DialogSelectDirectory } from "@/components/dialog-select-directory"
1644
+ -import { DialogSelectServer } from "@/components/dialog-select-server"
1645
+ -import { useServer } from "@/context/server"
1646
+ -import { useGlobalSync } from "@/context/global-sync"
1647
+ -import { useLanguage } from "@/context/language"
1648
+ +import { createSignal, onMount, Show } from "solid-js"
1649
+ +import { readPinnedWorkspace, workspaceHref } from "@/utils/waffle"
1650
+
1651
+ export default function Home() {
1652
+ - const sync = useGlobalSync()
1653
+ - const layout = useLayout()
1654
+ - const platform = usePlatform()
1655
+ - const dialog = useDialog()
1656
+ const navigate = useNavigate()
1657
+ - const server = useServer()
1658
+ - const language = useLanguage()
1659
+ - const homedir = createMemo(() => sync.data.path.home)
1660
+ - const recent = createMemo(() => {
1661
+ - return sync.data.project
1662
+ - .slice()
1663
+ - .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
1664
+ - .slice(0, 5)
1665
+ - })
1666
+ -
1667
+ - const serverDotClass = createMemo(() => {
1668
+ - const healthy = server.healthy()
1669
+ - if (healthy === true) return "bg-icon-success-base"
1670
+ - if (healthy === false) return "bg-icon-critical-base"
1671
+ - return "bg-border-weak-base"
1672
+ - })
1673
+ -
1674
+ - function openProject(directory: string) {
1675
+ - layout.projects.open(directory)
1676
+ - server.projects.touch(directory)
1677
+ - navigate(`/${base64Encode(directory)}`)
1678
+ - }
1679
+ + const [error, setError] = createSignal("")
1680
+
1681
+ - async function chooseProject() {
1682
+ - function resolve(result: string | string[] | null) {
1683
+ - if (Array.isArray(result)) {
1684
+ - for (const directory of result) {
1685
+ - openProject(directory)
1686
+ - }
1687
+ - } else if (result) {
1688
+ - openProject(result)
1689
+ + onMount(() => {
1690
+ + void (async () => {
1691
+ + try {
1692
+ + const workspace = await readPinnedWorkspace()
1693
+ + navigate(workspaceHref(workspace.directory), { replace: true })
1694
+ + } catch (error) {
1695
+ + setError(error instanceof Error ? error.message : "Failed to resolve pinned workspace")
1696
+ }
1697
+ - }
1698
+ -
1699
+ - if (platform.openDirectoryPickerDialog && server.isLocal()) {
1700
+ - const result = await platform.openDirectoryPickerDialog?.({
1701
+ - title: language.t("command.project.open"),
1702
+ - multiple: true,
1703
+ - })
1704
+ - resolve(result)
1705
+ - } else {
1706
+ - dialog.show(
1707
+ - () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
1708
+ - () => resolve(null),
1709
+ - )
1710
+ - }
1711
+ - }
1712
+ + })()
1713
+ + })
1714
+
1715
+ return (
1716
+ - <div class="mx-auto mt-55 w-full md:w-auto px-4">
1717
+ - <Logo class="md:w-xl opacity-12" />
1718
+ - <Button
1719
+ - size="large"
1720
+ - variant="ghost"
1721
+ - class="mt-4 mx-auto text-14-regular text-text-weak"
1722
+ - onClick={() => dialog.show(() => <DialogSelectServer />)}
1723
+ - >
1724
+ - <div
1725
+ - classList={{
1726
+ - "size-2 rounded-full": true,
1727
+ - [serverDotClass()]: true,
1728
+ - }}
1729
+ - />
1730
+ - {server.name}
1731
+ - </Button>
1732
+ - <Switch>
1733
+ - <Match when={sync.data.project.length > 0}>
1734
+ - <div class="mt-20 w-full flex flex-col gap-4">
1735
+ - <div class="flex gap-2 items-center justify-between pl-3">
1736
+ - <div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
1737
+ - <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
1738
+ - {language.t("command.project.open")}
1739
+ - </Button>
1740
+ - </div>
1741
+ - <ul class="flex flex-col gap-2">
1742
+ - <For each={recent()}>
1743
+ - {(project) => (
1744
+ - <Button
1745
+ - size="large"
1746
+ - variant="ghost"
1747
+ - class="text-14-mono text-left justify-between px-3"
1748
+ - onClick={() => openProject(project.worktree)}
1749
+ - >
1750
+ - {project.worktree.replace(homedir(), "~")}
1751
+ - <div class="text-14-regular text-text-weak">
1752
+ - {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
1753
+ - </div>
1754
+ - </Button>
1755
+ - )}
1756
+ - </For>
1757
+ - </ul>
1758
+ - </div>
1759
+ - </Match>
1760
+ - <Match when={true}>
1761
+ - <div class="mt-30 mx-auto flex flex-col items-center gap-3">
1762
+ - <Icon name="folder-add-left" size="large" />
1763
+ - <div class="flex flex-col gap-1 items-center justify-center">
1764
+ - <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
1765
+ - <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
1766
+ - </div>
1767
+ - <Button class="px-3 mt-1" onClick={chooseProject}>
1768
+ - {language.t("command.project.open")}
1769
+ - </Button>
1770
+ + <div class="mx-auto mt-55 w-full max-w-xl px-4">
1771
+ + <div class="rounded-xl border border-border-weak-base bg-surface-raised-base px-5 py-5">
1772
+ + <div class="text-16-medium text-text-strong">Agent Mockingbird</div>
1773
+ + <div class="mt-2 text-14-regular text-text-weak">Opening the pinned workspace…</div>
1774
+ + <Show when={error()}>
1775
+ + <div class="mt-4 rounded-lg border border-icon-critical-base/20 bg-icon-critical-base/8 px-3 py-2 text-13-regular text-text-strong">
1776
+ + {error()}
1777
+ </div>
1778
+ - </Match>
1779
+ - </Switch>
1780
+ + </Show>
1781
+ + </div>
1782
+ </div>
1783
+ )
1784
+ }
1785
+ diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
1786
+ index ab2687dca..5626c040b 100644
1787
+ --- a/packages/app/src/pages/layout.tsx
1788
+ +++ b/packages/app/src/pages/layout.tsx
1789
+ @@ -2,6 +2,8 @@ import {
1790
+ batch,
1791
+ createEffect,
1792
+ createMemo,
1793
+ + createResource,
1794
+ + createSignal,
1795
+ For,
1796
+ on,
1797
+ onCleanup,
1798
+ @@ -57,7 +59,6 @@ import { setSessionHandoff } from "@/pages/session/handoff"
1799
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
1800
+ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
1801
+ import { DialogSelectProvider } from "@/components/dialog-select-provider"
1802
+ -import { DialogSelectServer } from "@/components/dialog-select-server"
1803
+ import { DialogSettings } from "@/components/dialog-settings"
1804
+ import { useCommand, type CommandOption } from "@/context/command"
1805
+ import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
1806
+ @@ -90,6 +91,7 @@ import {
1807
+ } from "./layout/sidebar-workspace"
1808
+ import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
1809
+ import { SidebarContent } from "./layout/sidebar-shell"
1810
+ +import { readPinnedWorkspace } from "@/utils/waffle"
1811
+
1812
+ export default function Layout(props: ParentProps) {
1813
+ const [store, setStore, , ready] = persisted(
1814
+ @@ -127,6 +129,7 @@ export default function Layout(props: ParentProps) {
1815
+ const command = useCommand()
1816
+ const theme = useTheme()
1817
+ const language = useLanguage()
1818
+ + const [pinnedWorkspace] = createResource(readPinnedWorkspace)
1819
+ const initialDirectory = decode64(params.dir)
1820
+ const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
1821
+ const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
1822
+ @@ -137,6 +140,7 @@ export default function Layout(props: ParentProps) {
1823
+ }
1824
+ const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
1825
+ const currentDir = createMemo(() => decode64(params.dir) ?? "")
1826
+ + const pinnedDirectory = createMemo(() => pinnedWorkspace()?.directory?.trim() ?? "")
1827
+
1828
+ const [state, setState] = createStore({
1829
+ autoselect: !initialDirectory,
1830
+ @@ -572,6 +576,30 @@ export default function Layout(props: ParentProps) {
1831
+ return projects.find((p) => p.worktree === root)
1832
+ })
1833
+
1834
+ + const sidebarProjects = createMemo(() => {
1835
+ + const pinned = pinnedDirectory()
1836
+ + const projects = layout.projects.list()
1837
+ + if (!pinned) return projects
1838
+ + const pinnedKey = workspaceKey(pinned)
1839
+ + const matches = projects.filter((project) => {
1840
+ + if (workspaceKey(project.worktree) === pinnedKey) return true
1841
+ + return project.sandboxes?.some((directory) => workspaceKey(directory) === pinnedKey) ?? false
1842
+ + })
1843
+ + if (matches.length > 0) return matches
1844
+ +
1845
+ + const current = currentProject()
1846
+ + if (!current) return []
1847
+ + if (workspaceKey(current.worktree) === pinnedKey) return [current]
1848
+ + if (current.sandboxes?.some((directory) => workspaceKey(directory) === pinnedKey)) return [current]
1849
+ + return []
1850
+ + })
1851
+ +
1852
+ + createEffect(() => {
1853
+ + const directory = currentDir()
1854
+ + if (!directory) return
1855
+ + layout.projects.open(directory)
1856
+ + })
1857
+ +
1858
+ createEffect(
1859
+ on(
1860
+ () => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
1861
+ @@ -624,10 +652,7 @@ export default function Layout(props: ParentProps) {
1862
+ workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
1863
+
1864
+ const workspaceSetting = createMemo(() => {
1865
+ - const project = currentProject()
1866
+ - if (!project) return false
1867
+ - if (project.vcs !== "git") return false
1868
+ - return layout.sidebar.workspaces(project.worktree)()
1869
+ + return false
1870
+ })
1871
+
1872
+ const visibleSessionDirs = createMemo(() => {
1873
+ @@ -999,25 +1024,12 @@ export default function Layout(props: ParentProps) {
1874
+ keybind: "mod+b",
1875
+ onSelect: () => layout.sidebar.toggle(),
1876
+ },
1877
+ - {
1878
+ - id: "project.open",
1879
+ - title: language.t("command.project.open"),
1880
+ - category: language.t("command.category.project"),
1881
+ - keybind: "mod+o",
1882
+ - onSelect: () => chooseProject(),
1883
+ - },
1884
+ {
1885
+ id: "provider.connect",
1886
+ title: language.t("command.provider.connect"),
1887
+ category: language.t("command.category.provider"),
1888
+ onSelect: () => connectProvider(),
1889
+ },
1890
+ - {
1891
+ - id: "server.switch",
1892
+ - title: language.t("command.server.switch"),
1893
+ - category: language.t("command.category.server"),
1894
+ - onSelect: () => openServer(),
1895
+ - },
1896
+ {
1897
+ id: "settings.open",
1898
+ title: language.t("command.settings.open"),
1899
+ @@ -1064,41 +1076,6 @@ export default function Layout(props: ParentProps) {
1900
+ if (session) archiveSession(session)
1901
+ },
1902
+ },
1903
+ - {
1904
+ - id: "workspace.new",
1905
+ - title: language.t("workspace.new"),
1906
+ - category: language.t("command.category.workspace"),
1907
+ - keybind: "mod+shift+w",
1908
+ - disabled: !workspaceSetting(),
1909
+ - onSelect: () => {
1910
+ - const project = currentProject()
1911
+ - if (!project) return
1912
+ - return createWorkspace(project)
1913
+ - },
1914
+ - },
1915
+ - {
1916
+ - id: "workspace.toggle",
1917
+ - title: language.t("command.workspace.toggle"),
1918
+ - description: language.t("command.workspace.toggle.description"),
1919
+ - category: language.t("command.category.workspace"),
1920
+ - slash: "workspace",
1921
+ - disabled: !currentProject() || currentProject()?.vcs !== "git",
1922
+ - onSelect: () => {
1923
+ - const project = currentProject()
1924
+ - if (!project) return
1925
+ - if (project.vcs !== "git") return
1926
+ - const wasEnabled = layout.sidebar.workspaces(project.worktree)()
1927
+ - layout.sidebar.toggleWorkspaces(project.worktree)
1928
+ - showToast({
1929
+ - title: wasEnabled
1930
+ - ? language.t("toast.workspace.disabled.title")
1931
+ - : language.t("toast.workspace.enabled.title"),
1932
+ - description: wasEnabled
1933
+ - ? language.t("toast.workspace.disabled.description")
1934
+ - : language.t("toast.workspace.enabled.description"),
1935
+ - })
1936
+ - },
1937
+ - },
1938
+ {
1939
+ id: "theme.cycle",
1940
+ title: language.t("command.theme.cycle"),
1941
+ @@ -1165,10 +1142,6 @@ export default function Layout(props: ParentProps) {
1942
+ dialog.show(() => <DialogSelectProvider />)
1943
+ }
1944
+
1945
+ - function openServer() {
1946
+ - dialog.show(() => <DialogSelectServer />)
1947
+ - }
1948
+ -
1949
+ function openSettings() {
1950
+ dialog.show(() => <DialogSettings />)
1951
+ }
1952
+ @@ -2299,6 +2272,7 @@ export default function Layout(props: ParentProps) {
1953
+ arm()
1954
+ }}
1955
+ >
1956
+ + <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
1957
+ <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
1958
+ <Show when={layout.sidebar.opened()}>
1959
+ <div onPointerDown={() => setState("sizing", true)}>
1960
+ @@ -2347,6 +2321,7 @@ export default function Layout(props: ParentProps) {
1961
+ onClick={(e) => e.stopPropagation()}
1962
+ >
1963
+ {sidebarContent(true)}
1964
+ + {sidebarContent(true)}
1965
+ </nav>
1966
+ </div>
1967
+
1968
+ diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx
1969
+ index a26bc1831..ea7e4d9dc 100644
1970
+ --- a/packages/app/src/pages/layout/sidebar-project.tsx
1971
+ +++ b/packages/app/src/pages/layout/sidebar-project.tsx
1972
+ @@ -143,21 +143,6 @@ const ProjectTile = (props: {
1973
+ </ContextMenu.Trigger>
1974
+ <ContextMenu.Portal>
1975
+ <ContextMenu.Content>
1976
+ - <ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
1977
+ - <ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
1978
+ - </ContextMenu.Item>
1979
+ - <ContextMenu.Item
1980
+ - data-action="project-workspaces-toggle"
1981
+ - data-project={base64Encode(props.project.worktree)}
1982
+ - disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
1983
+ - onSelect={() => props.toggleProjectWorkspaces(props.project)}
1984
+ - >
1985
+ - <ContextMenu.ItemLabel>
1986
+ - {props.workspacesEnabled(props.project)
1987
+ - ? props.language.t("sidebar.workspaces.disable")
1988
+ - : props.language.t("sidebar.workspaces.enable")}
1989
+ - </ContextMenu.ItemLabel>
1990
+ - </ContextMenu.Item>
1991
+ <ContextMenu.Item
1992
+ data-action="project-clear-notifications"
1993
+ data-project={base64Encode(props.project.worktree)}
1994
+ @@ -166,14 +151,6 @@ const ProjectTile = (props: {
1995
+ >
1996
+ <ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
1997
+ </ContextMenu.Item>
1998
+ - <ContextMenu.Separator />
1999
+ - <ContextMenu.Item
2000
+ - data-action="project-close-menu"
2001
+ - data-project={base64Encode(props.project.worktree)}
2002
+ - onSelect={() => props.closeProject(props.project.worktree)}
2003
+ - >
2004
+ - <ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
2005
+ - </ContextMenu.Item>
2006
+ </ContextMenu.Content>
2007
+ </ContextMenu.Portal>
2008
+ </ContextMenu>
2009
+ diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx
2010
+ index ca36af2a4..924828c2f 100644
2011
+ --- a/packages/app/src/pages/layout/sidebar-shell.tsx
2012
+ +++ b/packages/app/src/pages/layout/sidebar-shell.tsx
2013
+ @@ -21,9 +21,9 @@ export const SidebarContent = (props: {
2014
+ handleDragStart: (event: unknown) => void
2015
+ handleDragEnd: () => void
2016
+ handleDragOver: (event: DragEvent) => void
2017
+ - openProjectLabel: JSX.Element
2018
+ - openProjectKeybind: Accessor<string | undefined>
2019
+ - onOpenProject: () => void
2020
+ + openProjectLabel?: JSX.Element
2021
+ + openProjectKeybind?: Accessor<string | undefined>
2022
+ + onOpenProject?: () => void
2023
+ renderProjectOverlay: () => JSX.Element
2024
+ settingsLabel: Accessor<string>
2025
+ settingsKeybind: Accessor<string | undefined>
2026
+ @@ -66,25 +66,6 @@ export const SidebarContent = (props: {
2027
+ <SortableProvider ids={props.projects().map((p) => p.worktree)}>
2028
+ <For each={props.projects()}>{(project) => props.renderProject(project)}</For>
2029
+ </SortableProvider>
2030
+ - <Tooltip
2031
+ - placement={placement()}
2032
+ - value={
2033
+ - <div class="flex items-center gap-2">
2034
+ - <span>{props.openProjectLabel}</span>
2035
+ - <Show when={!props.mobile && !!props.openProjectKeybind()}>
2036
+ - <span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span>
2037
+ - </Show>
2038
+ - </div>
2039
+ - }
2040
+ - >
2041
+ - <IconButton
2042
+ - icon="plus"
2043
+ - variant="ghost"
2044
+ - size="large"
2045
+ - onClick={props.onOpenProject}
2046
+ - aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined}
2047
+ - />
2048
+ - </Tooltip>
2049
+ </div>
2050
+ <DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
2051
+ </DragDropProvider>
2052
+ diff --git a/packages/app/src/utils/waffle.ts b/packages/app/src/utils/waffle.ts
2053
+ new file mode 100644
2054
+ index 000000000..79286f66b
2055
+ --- /dev/null
2056
+ +++ b/packages/app/src/utils/waffle.ts
2057
+ @@ -0,0 +1,61 @@
2058
+ +import { base64Encode } from "@opencode-ai/util/encode"
2059
+ +
2060
+ +export class WaffleApiError extends Error {
2061
+ + status: number
2062
+ +
2063
+ + constructor(message: string, status: number) {
2064
+ + super(message)
2065
+ + this.name = "WaffleApiError"
2066
+ + this.status = status
2067
+ + }
2068
+ +}
2069
+ +
2070
+ +async function parseJson<T>(response: Response): Promise<T> {
2071
+ + const contentType = response.headers.get("content-type") ?? ""
2072
+ + if (!contentType.includes("application/json")) {
2073
+ + throw new WaffleApiError(`Expected JSON response from ${response.url}`, response.status)
2074
+ + }
2075
+ +
2076
+ + return (await response.json()) as T
2077
+ +}
2078
+ +
2079
+ +export async function waffleJson<T>(path: string, init?: RequestInit): Promise<T> {
2080
+ + const response = await fetch(path, {
2081
+ + ...init,
2082
+ + headers: {
2083
+ + ...(init?.body ? { "Content-Type": "application/json" } : {}),
2084
+ + ...(init?.headers ?? {}),
2085
+ + },
2086
+ + })
2087
+ +
2088
+ + if (!response.ok) {
2089
+ + let message = `${response.status} ${response.statusText}`.trim()
2090
+ + try {
2091
+ + const payload = await parseJson<{ error?: string }>(response)
2092
+ + if (payload.error?.trim()) message = payload.error.trim()
2093
+ + } catch {
2094
+ + // Preserve the default HTTP status message when the error body is not JSON.
2095
+ + }
2096
+ + throw new WaffleApiError(message, response.status)
2097
+ + }
2098
+ +
2099
+ + return parseJson<T>(response)
2100
+ +}
2101
+ +
2102
+ +export function prettyJson(value: unknown) {
2103
+ + return JSON.stringify(value, null, 2)
2104
+ +}
2105
+ +
2106
+ +export function workspaceHref(directory: string, sessionID?: string) {
2107
+ + const slug = base64Encode(directory)
2108
+ + return sessionID ? `/${slug}/session/${sessionID}` : `/${slug}/session`
2109
+ +}
2110
+ +
2111
+ +export interface PinnedWorkspacePayload {
2112
+ + directory: string
2113
+ + hash: string
2114
+ +}
2115
+ +
2116
+ +export function readPinnedWorkspace() {
2117
+ + return waffleJson<PinnedWorkspacePayload>("/api/waffle/runtime/pinned-workspace")
2118
+ +}
2119
+ diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts
2120
+ index 631ca7811..870c7a7cb 100644
2121
+ --- a/packages/opencode/src/cli/cmd/providers.ts
2122
+ +++ b/packages/opencode/src/cli/cmd/providers.ts
2123
+ @@ -166,7 +166,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
2124
+ }
2125
+
2126
+ export function resolvePluginProviders(input: {
2127
+ - hooks: Hooks[]
2128
+ + hooks: Array<Hooks | null | undefined>
2129
+ existingProviders: Record<string, unknown>
2130
+ disabled: Set<string>
2131
+ enabled?: Set<string>
2132
+ @@ -176,6 +176,7 @@ export function resolvePluginProviders(input: {
2133
+ const result: Array<{ id: string; name: string }> = []
2134
+
2135
+ for (const hook of input.hooks) {
2136
+ + if (!hook) continue
2137
+ if (!hook.auth) continue
2138
+ const id = hook.auth.provider
2139
+ if (seen.has(id)) continue
2140
+ @@ -384,7 +385,7 @@ export const ProvidersLoginCommand = cmd({
2141
+ provider = selected as string
2142
+ }
2143
+
2144
+ - const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
2145
+ + const plugin = await Plugin.list().then((x) => x.findLast((x) => x?.auth?.provider === provider))
2146
+ if (plugin && plugin.auth) {
2147
+ const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
2148
+ if (handled) return
2149
+ @@ -398,7 +399,7 @@ export const ProvidersLoginCommand = cmd({
2150
+ if (prompts.isCancel(custom)) throw new UI.CancelledError()
2151
+ provider = custom.replace(/^@ai-sdk\//, "")
2152
+
2153
+ - const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
2154
+ + const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x?.auth?.provider === provider))
2155
+ if (customPlugin && customPlugin.auth) {
2156
+ const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
2157
+ if (handled) return
2158
+ diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
2159
+ index 8790efac4..1910ffd65 100644
2160
+ --- a/packages/opencode/src/plugin/index.ts
2161
+ +++ b/packages/opencode/src/plugin/index.ts
2162
+ @@ -21,6 +21,30 @@ export namespace Plugin {
2163
+ // Built-in plugins that are directly imported (not installed from npm)
2164
+ const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
2165
+
2166
+ + function isHooks(value: unknown): value is Hooks {
2167
+ + return Boolean(value) && typeof value === "object" && !Array.isArray(value)
2168
+ + }
2169
+ +
2170
+ + async function collectHooksFromModuleExports(mod: Record<string, unknown>, input: PluginInput) {
2171
+ + const hooks: Hooks[] = []
2172
+ + const seen = new Set<PluginInstance>()
2173
+ +
2174
+ + for (const [name, exported] of Object.entries(mod)) {
2175
+ + if (typeof exported !== "function") continue
2176
+ + const fn = exported as PluginInstance
2177
+ + if (seen.has(fn)) continue
2178
+ + seen.add(fn)
2179
+ + const init = await fn(input)
2180
+ + if (!isHooks(init)) {
2181
+ + log.info("ignoring non-hook plugin export", { name })
2182
+ + continue
2183
+ + }
2184
+ + hooks.push(init)
2185
+ + }
2186
+ +
2187
+ + return hooks
2188
+ + }
2189
+ +
2190
+ const state = Instance.state(async () => {
2191
+ const client = createOpencodeClient({
2192
+ baseUrl: "http://localhost:4096",
2193
+ @@ -85,12 +109,7 @@ export namespace Plugin {
2194
+ // Object.entries(mod) would return both entries pointing to the same function reference.
2195
+ await import(plugin)
2196
+ .then(async (mod) => {
2197
+ - const seen = new Set<PluginInstance>()
2198
+ - for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
2199
+ - if (seen.has(fn)) continue
2200
+ - seen.add(fn)
2201
+ - hooks.push(await fn(input))
2202
+ - }
2203
+ + hooks.push(...(await collectHooksFromModuleExports(mod as Record<string, unknown>, input)))
2204
+ })
2205
+ .catch((err) => {
2206
+ const message = err instanceof Error ? err.message : String(err)
2207
+ @@ -116,6 +135,7 @@ export namespace Plugin {
2208
+ >(name: Name, input: Input, output: Output): Promise<Output> {
2209
+ if (!name) return output
2210
+ for (const hook of await state().then((x) => x.hooks)) {
2211
+ + if (!hook) continue
2212
+ const fn = hook[name]
2213
+ if (!fn) continue
2214
+ // @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
2215
+ @@ -134,16 +154,23 @@ export namespace Plugin {
2216
+ const hooks = await state().then((x) => x.hooks)
2217
+ const config = await Config.get()
2218
+ for (const hook of hooks) {
2219
+ + if (!hook) continue
2220
+ // @ts-expect-error this is because we haven't moved plugin to sdk v2
2221
+ await hook.config?.(config)
2222
+ }
2223
+ Bus.subscribeAll(async (input) => {
2224
+ const hooks = await state().then((x) => x.hooks)
2225
+ for (const hook of hooks) {
2226
+ + if (!hook) continue
2227
+ hook["event"]?.({
2228
+ event: input,
2229
+ })
2230
+ }
2231
+ })
2232
+ }
2233
+ +
2234
+ + export const testing = {
2235
+ + collectHooksFromModuleExports,
2236
+ + isHooks,
2237
+ + }
2238
+ }
2239
+ diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
2240
+ index 349073197..40b608c1d 100644
2241
+ --- a/packages/opencode/src/provider/provider.ts
2242
+ +++ b/packages/opencode/src/provider/provider.ts
2243
+ @@ -999,6 +999,7 @@ export namespace Provider {
2244
+ }
2245
+
2246
+ for (const plugin of await Plugin.list()) {
2247
+ + if (!plugin) continue
2248
+ if (!plugin.auth) continue
2249
+ const providerID = ProviderID.make(plugin.auth.provider)
2250
+ if (disabled.has(providerID)) continue
2251
+ diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
2252
+ index 55bcf2dfc..7b67b5568 100644
2253
+ --- a/packages/opencode/src/server/server.ts
2254
+ +++ b/packages/opencode/src/server/server.ts
2255
+ @@ -54,7 +54,7 @@ export namespace Server {
2256
+
2257
+ export const Default = lazy(() => createApp({}))
2258
+
2259
+ - export const createApp = (opts: { cors?: string[] }): Hono => {
2260
+ + export const createApp = (opts: { cors?: string[]; localApp?: boolean }): Hono => {
2261
+ const app = new Hono()
2262
+ return app
2263
+ .onError((err, c) => {
2264
+ @@ -555,6 +555,10 @@ export namespace Server {
2265
+ },
2266
+ )
2267
+ .all("/*", async (c) => {
2268
+ + if (opts.localApp) {
2269
+ + return c.notFound()
2270
+ + }
2271
+ +
2272
+ const path = c.req.path
2273
+
2274
+ const response = await proxy(`https://app.opencode.ai${path}`, {
2275
+ diff --git a/packages/opencode/test/cli/plugin-auth-picker.test.ts b/packages/opencode/test/cli/plugin-auth-picker.test.ts
2276
+ index 5a1cf059d..33656399e 100644
2277
+ --- a/packages/opencode/test/cli/plugin-auth-picker.test.ts
2278
+ +++ b/packages/opencode/test/cli/plugin-auth-picker.test.ts
2279
+ @@ -117,4 +117,14 @@ describe("resolvePluginProviders", () => {
2280
+ })
2281
+ expect(result).toEqual([])
2282
+ })
2283
+ +
2284
+ + test("tolerates undefined hooks from malformed plugin exports", () => {
2285
+ + const result = resolvePluginProviders({
2286
+ + hooks: [undefined, hookWithAuth("portkey"), undefined],
2287
+ + existingProviders: {},
2288
+ + disabled: new Set(),
2289
+ + providerNames: {},
2290
+ + })
2291
+ + expect(result).toEqual([{ id: "portkey", name: "portkey" }])
2292
+ + })
2293
+ })
2294
+ diff --git a/packages/opencode/test/plugin/module-exports.test.ts b/packages/opencode/test/plugin/module-exports.test.ts
2295
+ new file mode 100644
2296
+ index 000000000..07c24b3ca
2297
+ --- /dev/null
2298
+ +++ b/packages/opencode/test/plugin/module-exports.test.ts
2299
+ @@ -0,0 +1,39 @@
2300
+ +import { describe, expect, test } from "bun:test"
2301
+ +
2302
+ +import { Plugin } from "../../src/plugin"
2303
+ +
2304
+ +describe("Plugin.testing.collectHooksFromModuleExports", () => {
2305
+ + test("ignores helper exports that do not return hooks", async () => {
2306
+ + const pluginFactory = async () => ({
2307
+ + auth: {
2308
+ + provider: "portkey",
2309
+ + methods: [],
2310
+ + },
2311
+ + })
2312
+ + const helper = () => undefined
2313
+ +
2314
+ + const hooks = await Plugin.testing.collectHooksFromModuleExports(
2315
+ + {
2316
+ + default: pluginFactory,
2317
+ + pluginFactory,
2318
+ + helper,
2319
+ + },
2320
+ + {} as never,
2321
+ + )
2322
+ +
2323
+ + expect(hooks).toHaveLength(1)
2324
+ + expect(hooks[0]?.auth?.provider).toBe("portkey")
2325
+ + })
2326
+ +
2327
+ + test("ignores non-function exports", async () => {
2328
+ + const hooks = await Plugin.testing.collectHooksFromModuleExports(
2329
+ + {
2330
+ + value: 123,
2331
+ + text: "hello",
2332
+ + },
2333
+ + {} as never,
2334
+ + )
2335
+ +
2336
+ + expect(hooks).toEqual([])
2337
+ + })
2338
+ +})
2339
+ --
2340
+ 2.53.0
2341
+