@polderlabs/bizar 3.23.1 → 4.2.0

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 (361) hide show
  1. package/README.md +83 -16
  2. package/bizar-dash/.bizar/activity.log +3 -0
  3. package/bizar-dash/.bizar/graph/.graphify_analysis.json +1540 -0
  4. package/bizar-dash/.bizar/graph/.graphify_labels.json +1 -0
  5. package/bizar-dash/.bizar/graph/GRAPH_REPORT.md +404 -0
  6. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/0661b816358db84dfdb7f10443db8a61152fd20b3fee950caf8fbf32920a1790.json +1 -0
  7. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/16da06d774142a6e5d74829ad982531286a6193ef798de8f593db029e123ec32.json +1 -0
  8. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/27e5697c01254c69c64c5a33896669073b40afb5ba88375102ae77fa5404136d.json +1 -0
  9. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/532997898027254aa4c0932243dc71d2f55888181232b65974961934997dc85b.json +1 -0
  10. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/84e3451a64c7f99f0cd6a88b0748d7426780486f53ab4935b7476b28c08f8293.json +1 -0
  11. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/88e5ea5f3e78fa69353fe9e6a8d1a05391d54605e25600cb125531eb5a6432ff.json +1 -0
  12. package/bizar-dash/.bizar/graph/cache/stat-index.json +1 -0
  13. package/bizar-dash/.bizar/graph/graph.html +307 -0
  14. package/bizar-dash/.bizar/graph/graph.json +40267 -0
  15. package/bizar-dash/.bizar/graph/manifest.json +762 -0
  16. package/bizar-dash/.graphifyignore +40 -0
  17. package/bizar-dash/.obsidian/README.md +14 -0
  18. package/bizar-dash/.obsidian/daily/2026-06-25.md +9 -0
  19. package/bizar-dash/.obsidian/vault.json +7 -0
  20. package/bizar-dash/.od-skills/web-prototype-e1429c1737/SKILL.md +103 -0
  21. package/bizar-dash/.od-skills/web-prototype-e1429c1737/assets/template.html +338 -0
  22. package/bizar-dash/.od-skills/web-prototype-e1429c1737/example.html +81 -0
  23. package/bizar-dash/.od-skills/web-prototype-e1429c1737/open-design.json +172 -0
  24. package/bizar-dash/.od-skills/web-prototype-e1429c1737/references/checklist.md +44 -0
  25. package/bizar-dash/.od-skills/web-prototype-e1429c1737/references/layouts.md +247 -0
  26. package/bizar-dash/CHANGELOG.md +474 -0
  27. package/bizar-dash/DESIGN.md +136 -0
  28. package/bizar-dash/dist/assets/main-B3RgW6FS.js +331 -0
  29. package/bizar-dash/dist/assets/main-B3RgW6FS.js.map +1 -0
  30. package/bizar-dash/dist/assets/main-DlJ8wgLR.css +1 -0
  31. package/bizar-dash/dist/assets/mobile-CsZQAswA.css +1 -0
  32. package/bizar-dash/dist/assets/mobile-DbxIw8BG.js +2 -0
  33. package/bizar-dash/dist/assets/mobile-DbxIw8BG.js.map +1 -0
  34. package/bizar-dash/dist/assets/mobile-Dvq2d53Y.js +354 -0
  35. package/bizar-dash/dist/assets/mobile-Dvq2d53Y.js.map +1 -0
  36. package/bizar-dash/dist/index.html +40 -0
  37. package/bizar-dash/dist/mobile.html +22 -0
  38. package/bizar-dash/plans/debug-plan/comments.json +1 -0
  39. package/bizar-dash/plans/debug-plan/meta.json +10 -0
  40. package/bizar-dash/plans/debug-plan/plan.json +174 -0
  41. package/bizar-dash/scripts/smoke-bg-retry.mjs +246 -0
  42. package/bizar-dash/src/cli/dashboard-ports.mjs +488 -0
  43. package/bizar-dash/src/cli.mjs +522 -0
  44. package/bizar-dash/src/server/activity-log.mjs +174 -0
  45. package/bizar-dash/src/server/agents-store.mjs +465 -0
  46. package/bizar-dash/src/server/api.mjs +138 -0
  47. package/bizar-dash/src/server/artifact-template.html +810 -0
  48. package/bizar-dash/src/server/artifacts-store.mjs +733 -0
  49. package/bizar-dash/src/server/auth.mjs +381 -0
  50. package/bizar-dash/src/server/background-store.mjs +447 -0
  51. package/bizar-dash/src/server/bg-poller.mjs +305 -0
  52. package/bizar-dash/src/server/bg-retry.mjs +574 -0
  53. package/bizar-dash/src/server/browser.mjs +40 -0
  54. package/bizar-dash/src/server/diagnostics-store.mjs +148 -0
  55. package/bizar-dash/src/server/dialog-poller.mjs +105 -0
  56. package/bizar-dash/src/server/dialog-store.mjs +177 -0
  57. package/bizar-dash/src/server/glyphs/mdx-compiler.mjs +583 -0
  58. package/bizar-dash/src/server/lib/path-safe.mjs +283 -0
  59. package/bizar-dash/src/server/memory-git.mjs +329 -0
  60. package/bizar-dash/src/server/memory-lightrag.mjs +767 -0
  61. package/bizar-dash/src/server/memory-schema.mjs +145 -0
  62. package/bizar-dash/src/server/memory-secrets.mjs +84 -0
  63. package/bizar-dash/src/server/memory-store.mjs +471 -0
  64. package/bizar-dash/src/server/mod-security.mjs +398 -0
  65. package/bizar-dash/src/server/mods-loader.mjs +1067 -0
  66. package/bizar-dash/src/server/notifications-store.mjs +243 -0
  67. package/bizar-dash/src/server/obsidian-store.mjs +318 -0
  68. package/bizar-dash/src/server/opencode-runner.mjs +279 -0
  69. package/bizar-dash/src/server/pair-store.mjs +138 -0
  70. package/bizar-dash/src/server/projects-store.mjs +364 -0
  71. package/bizar-dash/src/server/providers-store.mjs +789 -0
  72. package/bizar-dash/src/server/routes/_shared.mjs +415 -0
  73. package/bizar-dash/src/server/routes/activity.mjs +110 -0
  74. package/bizar-dash/src/server/routes/agents.mjs +134 -0
  75. package/bizar-dash/src/server/routes/artifacts.mjs +279 -0
  76. package/bizar-dash/src/server/routes/auth.mjs +69 -0
  77. package/bizar-dash/src/server/routes/background.mjs +165 -0
  78. package/bizar-dash/src/server/routes/chat.mjs +485 -0
  79. package/bizar-dash/src/server/routes/config.mjs +129 -0
  80. package/bizar-dash/src/server/routes/diagnostics.mjs +50 -0
  81. package/bizar-dash/src/server/routes/dialogs.mjs +86 -0
  82. package/bizar-dash/src/server/routes/fs.mjs +429 -0
  83. package/bizar-dash/src/server/routes/history.mjs +78 -0
  84. package/bizar-dash/src/server/routes/memory.mjs +658 -0
  85. package/bizar-dash/src/server/routes/misc.mjs +89 -0
  86. package/bizar-dash/src/server/routes/mods.mjs +359 -0
  87. package/bizar-dash/src/server/routes/notifications.mjs +54 -0
  88. package/bizar-dash/src/server/routes/obsidian.mjs +239 -0
  89. package/bizar-dash/src/server/routes/opencode-sessions.mjs +55 -0
  90. package/bizar-dash/src/server/routes/overview.mjs +91 -0
  91. package/bizar-dash/src/server/routes/pair.mjs +67 -0
  92. package/bizar-dash/src/server/routes/projects.mjs +175 -0
  93. package/bizar-dash/src/server/routes/providers.mjs +47 -0
  94. package/bizar-dash/src/server/routes/schedules.mjs +104 -0
  95. package/bizar-dash/src/server/routes/settings.mjs +51 -0
  96. package/bizar-dash/src/server/routes/skills.mjs +76 -0
  97. package/bizar-dash/src/server/routes/tasks.mjs +468 -0
  98. package/bizar-dash/src/server/routes/themes.mjs +51 -0
  99. package/bizar-dash/src/server/routes-v2/auth.mjs +59 -0
  100. package/bizar-dash/src/server/routes-v2/events.mjs +107 -0
  101. package/bizar-dash/src/server/routes-v2/health.mjs +21 -0
  102. package/bizar-dash/src/server/routes-v2/index.mjs +90 -0
  103. package/bizar-dash/src/server/routes-v2/sessions.mjs +111 -0
  104. package/bizar-dash/src/server/schedules-runner.mjs +369 -0
  105. package/bizar-dash/src/server/schedules-store.mjs +268 -0
  106. package/bizar-dash/src/server/search-store.mjs +220 -0
  107. package/bizar-dash/src/server/serve-info.mjs +684 -0
  108. package/bizar-dash/src/server/server.mjs +796 -0
  109. package/bizar-dash/src/server/settings-store.mjs +45 -0
  110. package/bizar-dash/src/server/skills-store.mjs +300 -0
  111. package/bizar-dash/src/server/state.mjs +437 -0
  112. package/bizar-dash/src/server/tailscale-store.mjs +113 -0
  113. package/bizar-dash/src/server/task-delegator.mjs +824 -0
  114. package/bizar-dash/src/server/tasks-store.mjs +508 -0
  115. package/bizar-dash/src/server/tui.mjs +923 -0
  116. package/bizar-dash/src/server/update-store.mjs +168 -0
  117. package/bizar-dash/src/server/v2-auth-file.mjs +99 -0
  118. package/bizar-dash/src/server/v2-event-bus.mjs +128 -0
  119. package/bizar-dash/src/server/watcher.mjs +81 -0
  120. package/bizar-dash/src/server/yaml.mjs +238 -0
  121. package/bizar-dash/src/web/App.tsx +718 -0
  122. package/bizar-dash/src/web/MobileApp.tsx +408 -0
  123. package/bizar-dash/src/web/components/ArtifactCreateDialog.tsx +95 -0
  124. package/bizar-dash/src/web/components/ArtifactListDialog.tsx +55 -0
  125. package/bizar-dash/src/web/components/ArtifactViewer.tsx +143 -0
  126. package/bizar-dash/src/web/components/AuditDialog.tsx +79 -0
  127. package/bizar-dash/src/web/components/BgStatusBadge.tsx +32 -0
  128. package/bizar-dash/src/web/components/Button.tsx +56 -0
  129. package/bizar-dash/src/web/components/CanvasContextMenu.tsx +68 -0
  130. package/bizar-dash/src/web/components/Card.tsx +40 -0
  131. package/bizar-dash/src/web/components/CollapsibleSection.tsx +72 -0
  132. package/bizar-dash/src/web/components/CommandDialog.tsx +66 -0
  133. package/bizar-dash/src/web/components/EmptyState.tsx +30 -0
  134. package/bizar-dash/src/web/components/FileBrowser.tsx +633 -0
  135. package/bizar-dash/src/web/components/HelpDialog.tsx +55 -0
  136. package/bizar-dash/src/web/components/KillConfirmDialog.tsx +88 -0
  137. package/bizar-dash/src/web/components/Modal.tsx +215 -0
  138. package/bizar-dash/src/web/components/Notifications.tsx +241 -0
  139. package/bizar-dash/src/web/components/SearchModal.tsx +224 -0
  140. package/bizar-dash/src/web/components/Sidebar.tsx +66 -0
  141. package/bizar-dash/src/web/components/Spinner.tsx +19 -0
  142. package/bizar-dash/src/web/components/StatusBadge.tsx +25 -0
  143. package/bizar-dash/src/web/components/Tag.tsx +28 -0
  144. package/bizar-dash/src/web/components/Toast.tsx +150 -0
  145. package/bizar-dash/src/web/components/Topbar.tsx +368 -0
  146. package/bizar-dash/src/web/components/VisualPlanDialog.tsx +58 -0
  147. package/bizar-dash/src/web/components/chat/AgentChip.tsx +68 -0
  148. package/bizar-dash/src/web/components/chat/ChatBubble.tsx +60 -0
  149. package/bizar-dash/src/web/components/chat/ChatThread.tsx +88 -0
  150. package/bizar-dash/src/web/components/chat/ChatTopBar.tsx +50 -0
  151. package/bizar-dash/src/web/components/chat/Composer.tsx +164 -0
  152. package/bizar-dash/src/web/components/chat/ConfirmModal.tsx +54 -0
  153. package/bizar-dash/src/web/components/chat/EmptyState.tsx +21 -0
  154. package/bizar-dash/src/web/components/chat/FirstRunGreeting.tsx +18 -0
  155. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +32 -0
  156. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +119 -0
  157. package/bizar-dash/src/web/components/chat/LoadingSkeleton.tsx +19 -0
  158. package/bizar-dash/src/web/components/chat/MessageBubble.tsx +60 -0
  159. package/bizar-dash/src/web/components/chat/SessionList.tsx +153 -0
  160. package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +9 -0
  161. package/bizar-dash/src/web/components/chat/SuggestionCards.tsx +55 -0
  162. package/bizar-dash/src/web/components/chat/WelcomeScreen.tsx +36 -0
  163. package/bizar-dash/src/web/components/chat/index.ts +20 -0
  164. package/bizar-dash/src/web/components/chat/useAutoGrowTextarea.ts +18 -0
  165. package/bizar-dash/src/web/components/chat/useChat.ts +219 -0
  166. package/bizar-dash/src/web/components/chat/useSlashCommands.ts +47 -0
  167. package/bizar-dash/src/web/index.html +37 -0
  168. package/bizar-dash/src/web/lib/api.ts +193 -0
  169. package/bizar-dash/src/web/lib/markdown.tsx +59 -0
  170. package/bizar-dash/src/web/lib/types.ts +668 -0
  171. package/bizar-dash/src/web/lib/utils.ts +114 -0
  172. package/bizar-dash/src/web/lib/ws.ts +176 -0
  173. package/bizar-dash/src/web/main.tsx +34 -0
  174. package/bizar-dash/src/web/mobile/MobileApp.tsx +278 -0
  175. package/bizar-dash/src/web/mobile/MobileBottomNav.tsx +38 -0
  176. package/bizar-dash/src/web/mobile/MobileTopbar.tsx +115 -0
  177. package/bizar-dash/src/web/mobile/components/MobileBottomSheet.tsx +182 -0
  178. package/bizar-dash/src/web/mobile/components/MobileListItem.tsx +64 -0
  179. package/bizar-dash/src/web/mobile/components/MobileModal.tsx +123 -0
  180. package/bizar-dash/src/web/mobile/views/MobileActivity.tsx +273 -0
  181. package/bizar-dash/src/web/mobile/views/MobileAgents.tsx +374 -0
  182. package/bizar-dash/src/web/mobile/views/MobileArtifactCanvas.tsx +439 -0
  183. package/bizar-dash/src/web/mobile/views/MobileArtifacts.tsx +143 -0
  184. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +180 -0
  185. package/bizar-dash/src/web/mobile/views/MobileConfig.tsx +175 -0
  186. package/bizar-dash/src/web/mobile/views/MobileHistory.tsx +191 -0
  187. package/bizar-dash/src/web/mobile/views/MobileMods.tsx +149 -0
  188. package/bizar-dash/src/web/mobile/views/MobileMore.tsx +121 -0
  189. package/bizar-dash/src/web/mobile/views/MobileNotifications.tsx +137 -0
  190. package/bizar-dash/src/web/mobile/views/MobilePlans.tsx +26 -0
  191. package/bizar-dash/src/web/mobile/views/MobileSchedules.tsx +685 -0
  192. package/bizar-dash/src/web/mobile/views/MobileSearchModal.tsx +172 -0
  193. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +388 -0
  194. package/bizar-dash/src/web/mobile/views/MobileSkills.tsx +153 -0
  195. package/bizar-dash/src/web/mobile/views/MobileTasks.tsx +564 -0
  196. package/bizar-dash/src/web/mobile.html +20 -0
  197. package/bizar-dash/src/web/mobile.tsx +13 -0
  198. package/bizar-dash/src/web/styles/chat.css +198 -0
  199. package/bizar-dash/src/web/styles/glyphs.css +960 -0
  200. package/bizar-dash/src/web/styles/main.css +8855 -0
  201. package/bizar-dash/src/web/styles/mobile-chat.css +5 -0
  202. package/bizar-dash/src/web/styles/mobile.css +1960 -0
  203. package/bizar-dash/src/web/views/Activity.tsx +1203 -0
  204. package/bizar-dash/src/web/views/Agents.tsx +701 -0
  205. package/bizar-dash/src/web/views/Artifacts.tsx +1480 -0
  206. package/bizar-dash/src/web/views/BackgroundAgents.tsx +450 -0
  207. package/bizar-dash/src/web/views/Chat.tsx +190 -0
  208. package/bizar-dash/src/web/views/Config.tsx +2063 -0
  209. package/bizar-dash/src/web/views/History.tsx +335 -0
  210. package/bizar-dash/src/web/views/ModView.tsx +207 -0
  211. package/bizar-dash/src/web/views/Mods.tsx +693 -0
  212. package/bizar-dash/src/web/views/Overview.tsx +812 -0
  213. package/bizar-dash/src/web/views/Providers.tsx +302 -0
  214. package/bizar-dash/src/web/views/Schedules.tsx +802 -0
  215. package/bizar-dash/src/web/views/Settings.tsx +1787 -0
  216. package/bizar-dash/src/web/views/Skills.tsx +431 -0
  217. package/bizar-dash/src/web/views/Tasks.tsx +1523 -0
  218. package/bizar-dash/src/web/views/glyphs/GlyphRenderer.tsx +613 -0
  219. package/bizar-dash/src/web/views/glyphs/components.tsx +1098 -0
  220. package/bizar-dash/templates/mod/FORMAT.md +76 -0
  221. package/bizar-dash/templates/mod/hello-mod/README.md +19 -0
  222. package/bizar-dash/templates/mod/hello-mod/agents/greeter.md +8 -0
  223. package/bizar-dash/templates/mod/hello-mod/commands/hello.md +6 -0
  224. package/bizar-dash/templates/mod/hello-mod/mod.json +20 -0
  225. package/bizar-dash/templates/mod/hello-mod/routes/ping.mjs +9 -0
  226. package/bizar-dash/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  227. package/bizar-dash/tests/dashboard-ports.test.mjs +138 -0
  228. package/bizar-dash/tests/graphify-mod-spawn.node.test.mjs +90 -0
  229. package/bizar-dash/tests/memory-cli.test.mjs +542 -0
  230. package/bizar-dash/tests/memory-config.test.mjs +422 -0
  231. package/bizar-dash/tests/memory-git.test.mjs +246 -0
  232. package/bizar-dash/tests/memory-lightrag.test.mjs +153 -0
  233. package/bizar-dash/tests/memory-protocol-drift.test.mjs +45 -0
  234. package/bizar-dash/tests/memory-schema.test.mjs +198 -0
  235. package/bizar-dash/tests/memory-secrets.test.mjs +121 -0
  236. package/bizar-dash/tests/memory-store.test.mjs +390 -0
  237. package/bizar-dash/tests/memory-sync.test.mjs +141 -0
  238. package/bizar-dash/tests/mod-instructions.node.test.mjs +188 -0
  239. package/bizar-dash/tests/mod-security.test.mjs +273 -0
  240. package/bizar-dash/tests/mod-upgrade.node.test.mjs +357 -0
  241. package/bizar-dash/tests/no-agent-browser.node.test.mjs +98 -0
  242. package/bizar-dash/tests/obsidian-back-compat.test.mjs +199 -0
  243. package/bizar-dash/tests/opencode-runner.test.mjs +172 -0
  244. package/bizar-dash/tests/path-safe.test.mjs +108 -0
  245. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +333 -0
  246. package/bizar-dash/tests/smoke-v2.mjs +212 -0
  247. package/bizar-dash/tests/strip-thinking.test.mjs +175 -0
  248. package/bizar-dash/tests/submit-feedback.test.mjs +353 -0
  249. package/bizar-dash/tests/tmux-wrap.test.mjs +145 -0
  250. package/bizar-dash/tests/yaml.test.mjs +128 -0
  251. package/bizar-dash/tsconfig.json +23 -0
  252. package/bizar-dash/vite.config.ts +27 -0
  253. package/cli/atomic.mjs +73 -0
  254. package/cli/banner.mjs +1 -1
  255. package/cli/bin.mjs +44 -47
  256. package/cli/bootstrap.mjs +1 -1
  257. package/cli/copy.mjs +25 -16
  258. package/cli/dev-link.mjs +17 -1
  259. package/cli/doctor.mjs +4 -4
  260. package/cli/doctor.test.mjs +2 -2
  261. package/cli/init.mjs +135 -1
  262. package/cli/install.mjs +31 -39
  263. package/cli/memory-constants.mjs +59 -0
  264. package/cli/memory.mjs +1436 -0
  265. package/cli/service.mjs +5 -6
  266. package/cli/utils.mjs +6 -3
  267. package/config/AGENTS.md +7 -7
  268. package/config/agents/_shared/AGENT_BASELINE.md +59 -61
  269. package/config/opencode.json +13 -38
  270. package/config/skills/memory-protocol/SKILL.md +105 -0
  271. package/config/skills/obsidian/SKILL.md +270 -65
  272. package/install.sh +59 -19
  273. package/package.json +49 -14
  274. package/packages/sdk/LICENSE +21 -0
  275. package/packages/sdk/README.md +80 -0
  276. package/packages/sdk/src/client.ts +183 -0
  277. package/packages/sdk/src/errors.ts +120 -0
  278. package/packages/sdk/src/events.ts +153 -0
  279. package/packages/sdk/src/index.ts +63 -0
  280. package/packages/sdk/src/types.ts +176 -0
  281. package/packages/sdk/src/version.ts +4 -0
  282. package/packages/sdk/tests/client.test.ts +217 -0
  283. package/packages/sdk/tests/errors.test.ts +108 -0
  284. package/packages/sdk/tests/events.test.ts +139 -0
  285. package/packages/sdk/tests/fixtures/fetch-mock.ts +152 -0
  286. package/packages/sdk/tests/fixtures/sse-mock.ts +30 -0
  287. package/packages/sdk/tsconfig.json +25 -0
  288. package/packages/sdk/vitest.config.ts +9 -0
  289. package/plugins/bizar/LICENSE +21 -0
  290. package/plugins/bizar/README.md +448 -0
  291. package/plugins/bizar/dist/index.js +29901 -0
  292. package/plugins/bizar/index.ts +1491 -0
  293. package/plugins/bizar/scripts/check-forbidden-imports.sh +33 -0
  294. package/plugins/bizar/src/background-state.ts +551 -0
  295. package/plugins/bizar/src/background.ts +1250 -0
  296. package/plugins/bizar/src/commands-impl.ts +464 -0
  297. package/plugins/bizar/src/commands.ts +1168 -0
  298. package/plugins/bizar/src/dashboard-client.ts +233 -0
  299. package/plugins/bizar/src/event-stream.ts +606 -0
  300. package/plugins/bizar/src/fingerprint.ts +120 -0
  301. package/plugins/bizar/src/handoff.ts +79 -0
  302. package/plugins/bizar/src/http-client.ts +467 -0
  303. package/plugins/bizar/src/key-rotation.ts +218 -0
  304. package/plugins/bizar/src/logger.ts +144 -0
  305. package/plugins/bizar/src/loop.ts +176 -0
  306. package/plugins/bizar/src/opencode-runner.ts +390 -0
  307. package/plugins/bizar/src/options.ts +421 -0
  308. package/plugins/bizar/src/plan-fs.ts +323 -0
  309. package/plugins/bizar/src/reasoning-clean.ts +454 -0
  310. package/plugins/bizar/src/report.ts +178 -0
  311. package/plugins/bizar/src/research-prompt.ts +35 -0
  312. package/plugins/bizar/src/serve-info.ts +228 -0
  313. package/plugins/bizar/src/serve.ts +496 -0
  314. package/plugins/bizar/src/settings.ts +349 -0
  315. package/plugins/bizar/src/state.ts +298 -0
  316. package/plugins/bizar/src/tools/bg-collect.ts +104 -0
  317. package/plugins/bizar/src/tools/bg-get-comments.ts +239 -0
  318. package/plugins/bizar/src/tools/bg-kill.ts +87 -0
  319. package/plugins/bizar/src/tools/bg-spawn.ts +401 -0
  320. package/plugins/bizar/src/tools/bg-status.ts +99 -0
  321. package/plugins/bizar/src/tools/open-kb.ts +191 -0
  322. package/plugins/bizar/src/tools/plan-action.ts +767 -0
  323. package/plugins/bizar/src/tools/read-glyph-feedback.ts +170 -0
  324. package/plugins/bizar/src/tools/wait-for-feedback.ts +402 -0
  325. package/plugins/bizar/tests/attach-handler-bug.test.ts +169 -0
  326. package/plugins/bizar/tests/background-state.test.ts +277 -0
  327. package/plugins/bizar/tests/background.test.ts +402 -0
  328. package/plugins/bizar/tests/block.test.ts +195 -0
  329. package/plugins/bizar/tests/canonical-key-order.test.ts +75 -0
  330. package/plugins/bizar/tests/commands-impl.test.ts +442 -0
  331. package/plugins/bizar/tests/commands.test.ts +584 -0
  332. package/plugins/bizar/tests/config.test.ts +122 -0
  333. package/plugins/bizar/tests/dashboard-client.test.ts +159 -0
  334. package/plugins/bizar/tests/dispose.test.ts +336 -0
  335. package/plugins/bizar/tests/event-stream.test.ts +409 -0
  336. package/plugins/bizar/tests/event.test.ts +262 -0
  337. package/plugins/bizar/tests/fingerprint.test.ts +162 -0
  338. package/plugins/bizar/tests/http-client.test.ts +404 -0
  339. package/plugins/bizar/tests/init-helpers.test.ts +203 -0
  340. package/plugins/bizar/tests/integration/slash-command.test.ts +348 -0
  341. package/plugins/bizar/tests/integration/tool-routing.test.ts +314 -0
  342. package/plugins/bizar/tests/key-rotation.test.ts +396 -0
  343. package/plugins/bizar/tests/loop.test.ts +397 -0
  344. package/plugins/bizar/tests/options.test.ts +276 -0
  345. package/plugins/bizar/tests/reasoning-clean.test.ts +422 -0
  346. package/plugins/bizar/tests/serve.test.ts +339 -0
  347. package/plugins/bizar/tests/settings.test.ts +351 -0
  348. package/plugins/bizar/tests/stall-think.test.ts +750 -0
  349. package/plugins/bizar/tests/state.test.ts +276 -0
  350. package/plugins/bizar/tests/tools/bg-collect.test.ts +337 -0
  351. package/plugins/bizar/tests/tools/bg-get-comments.test.ts +485 -0
  352. package/plugins/bizar/tests/tools/bg-kill.test.ts +235 -0
  353. package/plugins/bizar/tests/tools/bg-spawn-delegation.test.ts +147 -0
  354. package/plugins/bizar/tests/tools/bg-spawn.test.ts +311 -0
  355. package/plugins/bizar/tests/tools/bg-status.test.ts +217 -0
  356. package/plugins/bizar/tests/tools/opencode-runner.test.ts +115 -0
  357. package/plugins/bizar/tests/tools/plan-action.test.ts +599 -0
  358. package/plugins/bizar/tests/tools/read-glyph-feedback.test.ts +253 -0
  359. package/plugins/bizar/tests/tools/wait-for-feedback.test.ts +390 -0
  360. package/plugins/bizar/tests/update-deadlock.test.ts +145 -0
  361. package/plugins/bizar/tsconfig.json +29 -0
@@ -0,0 +1,1491 @@
1
+ /**
2
+ * Bizar plugin — opencode plugin entry point.
3
+ *
4
+ * Spec contract (cumulative):
5
+ * v0.3.1:
6
+ * - §3.1 — hook surface used. The plugin wires up `config`, `event`,
7
+ * `chat.message`, `tool.execute.before`, `tool.execute.after`, and
8
+ * `experimental.chat.system.transform`.
9
+ * - §4.3 — per-session async mutex. All state reads/writes go through
10
+ * `stateStore.withLock(sessionID, …)`.
11
+ * - §4.5 — `chat.message` dedupes by message ID and seeds `parentAgent`
12
+ * on the first message per session.
13
+ * - §4.5.1 — state file is created on the `chat.message` seed (not on
14
+ * `session.created`). A lazy fallback creates the file on the first
15
+ * `tool.execute.before` for subagent-only sessions.
16
+ * - §4.6 — stale session cleanup runs on init.
17
+ * - §4.7 — corrupt-state fallback. StateStore handles this; we never
18
+ * see a corrupt file from the hook side.
19
+ * - §5.4 — thresholds 5 and 8 inject via `experimental.chat.system.transform`;
20
+ * threshold 12 throws from `tool.execute.before`.
21
+ * - §6.4 — refuse to start if `logDir` or `stateDir` is inside a secret
22
+ * directory. Return empty hooks in that case.
23
+ * - §6.5 — honor `BIZAR_DISABLE`, `BIZAR_DISABLE_LOOP`, `BIZAR_DISABLE_LOG`,
24
+ * `BIZAR_LOG_LEVEL`. Read once at init.
25
+ * - §7.6 — never log raw tool args. The logger only accepts message strings.
26
+ * - §8.1 — wrap init in try/catch. Return empty hooks on any init error.
27
+ * - §8.2 — create directories on init. If creation fails, return empty hooks.
28
+ * - §10.1 — per-call log line via `LogWriter.write`.
29
+ *
30
+ * v0.4.0 (visual plan flow — slash commands + plan tools):
31
+ * - §v4.1 — `chat.message` hook detects slash commands BEFORE state
32
+ * seeding. Settings changes are applied silently; commands with
33
+ * a response text are surfaced by throwing from the hook (the
34
+ * same pattern `tool.execute.before` uses for block decisions).
35
+ * - §v4.2 — `SettingsStore` persists user-controlled plan settings
36
+ * (visualPlanEnabled, defaultTemplate, lastUsedSlug) at
37
+ * `~/.cache/bizar/plan-settings.json`. Atomic writes,
38
+ * corrupt-file fallback to defaults, no throw on bad input.
39
+ * - §v4.3 — `parseSlashCommand` is a pure function (no I/O). The
40
+ * hook gathers context (current settings, available plan slugs)
41
+ * and feeds it to the parser.
42
+ * - §v4.4 — `bizar_plan_action` tool exposes CRUD on the v2
43
+ * canvas (`plan.json`) and the plan metadata (`meta.json`).
44
+ * Pure file I/O — no serve child required.
45
+ * - §v4.5 — `bizar_wait_for_feedback` tool polls every 2 s until
46
+ * a new comment appears, status becomes approved/rejected, or
47
+ * the timeout fires. Never throws.
48
+ *
49
+ * v0.5.0 (visual plan wiring — chat hook executes side effects):
50
+ * - §v5.1 — `chat.message` hook now invokes the parser, then
51
+ * calls `executeSideEffect(result.sideEffect, ctx, opts)` from
52
+ * `src/commands-impl.ts` BEFORE throwing the response. Side
53
+ * effects include `create_plan` (mkdir + write meta/canvas
54
+ * via `src/plan-fs.ts`), `list_plans` (re-read directory and
55
+ * return rich list), `open_plan_url` (no I/O), and
56
+ * `tool_invocation` (build synthetic `ToolContext`, validate
57
+ * args via the tool's Zod schema, then call `tool.execute`).
58
+ * - §v5.2 — synthetic `ToolContext` is built from the runtime
59
+ * context's `worktree` and `directory`, a fresh
60
+ * `AbortController().signal`, and no-op `metadata`/`ask`
61
+ * stubs. NOT from the chat-message input. Session/message/agent
62
+ * IDs use a `"slash-command"` sentinel so downstream code can
63
+ * recognize out-of-band calls.
64
+ * - §v5.3 — tool key names use the `bizar_*` form (single `r`)
65
+ * throughout the plugin, matching the docs and
66
+ * `config/opencode.json`. The earlier `bizarre_*` typo silently
67
+ * disabled the plan tools at runtime; the rename brings the
68
+ * runtime registry back in sync.
69
+ * - §v5.4 — subcommand form: `/plan get|add|update|delete|comment|
70
+ * comments|status|wait` route through `bizar_plan_action` (or
71
+ * `bizar_get_plan_comments`) via the new `tool_invocation`
72
+ * side-effect. `/plan wait` is deferred from MVP and returns
73
+ * a clear "use bizar_wait_for_feedback directly" response.
74
+ *
75
+ * v0.4.2 (background agents):
76
+ * - §1 — start `opencode serve` on init; spawn background sessions
77
+ * via `POST /session` + `POST /session/{id}/prompt_async`.
78
+ * - §2.1 — open ONE global SSE subscription to `GET /event`.
79
+ * - §2.2 — `InstanceManager.add()` is atomic.
80
+ * - §5.1 — serve child on 127.0.0.1 with `--hostname` hardcoded.
81
+ * - §5.3 — SIGTERM/SIGINT trap walks the in-memory map, aborts
82
+ * running sessions, kills the serve child, exits.
83
+ * - §5.4 — on init, scan `bg/*.json`; rebuild in-memory map; mark
84
+ * orphaned `running`/`pending` as `failed`.
85
+ * - §6.1 — 32-byte secret for the serve child; `node:crypto` only in `serve.ts`.
86
+ * - §6.3 — only Odin may call `bizar_spawn_background`.
87
+ * - §7.1 — register 4 background tools: `bizar_spawn_background`,
88
+ * `bizar_status`, `bizar_collect`, `bizar_kill`.
89
+ * - §v2.1 — register 1 read-only tool: `bizar_get_plan_comments`.
90
+ * Reads `plans/<slug>/plan.json` so background agents can pick up
91
+ * user feedback pinned to the elements they're working on.
92
+ * Available to all agents (read-only — no serve child required).
93
+ * - §5.5 — `--hostname 127.0.0.1` hardcoded.
94
+ */
95
+
96
+ import type { Plugin, Hooks, PluginInput, PluginOptions } from "@opencode-ai/plugin";
97
+
98
+ import { createLogger, type Logger } from "./src/logger.js";
99
+ import { decide, isLogOnlyWarn } from "./src/loop.js";
100
+ import { fingerprint } from "./src/fingerprint.js";
101
+ import { createDashboardPublisher, type DashboardPublisher } from "./src/dashboard-client.js";
102
+ import { StateStore, type SessionState } from "./src/state.js";
103
+ import { LogWriter } from "./src/report.js";
104
+ import {
105
+ normalizeOptions,
106
+ readEnvFlags,
107
+ findOffendingPath,
108
+ type NormalizedOptions,
109
+ type EnvFlags,
110
+ } from "./src/options.js";
111
+
112
+ import { ServeLifecycle } from "./src/serve.js";
113
+ import { writeServeInfo, clearServeInfo } from "./src/serve-info.js";
114
+ import { HttpClient } from "./src/http-client.js";
115
+ import { EventStream } from "./src/event-stream.js";
116
+ import { BackgroundStateStore, type BackgroundState } from "./src/background-state.js";
117
+ import { InstanceManager } from "./src/background.js";
118
+ import { createBgSpawnTool } from "./src/tools/bg-spawn.js";
119
+ import { createBgStatusTool } from "./src/tools/bg-status.js";
120
+ import { createBgCollectTool } from "./src/tools/bg-collect.js";
121
+ import { createBgKillTool } from "./src/tools/bg-kill.js";
122
+ import { createBgGetCommentsTool } from "./src/tools/bg-get-comments.js";
123
+ import { createOpenKbTool } from "./src/tools/open-kb.js";
124
+
125
+ // v0.4.0 — visual plan flow: settings, slash commands, plan tools
126
+ import { SettingsStore } from "./src/settings.js";
127
+ import { parseSlashCommand } from "./src/commands.js";
128
+ import { createPlanActionTool } from "./src/tools/plan-action.js";
129
+ import { createWaitForFeedbackTool } from "./src/tools/wait-for-feedback.js";
130
+ import { createReadGlyphFeedbackTool } from "./src/tools/read-glyph-feedback.js";
131
+ import {
132
+ stripInlineThinkBlocks,
133
+ wrapFetchForReasoningCleanup,
134
+ type FetchLike,
135
+ } from "./src/reasoning-clean.js";
136
+ import {
137
+ wrapFetchForKeyRotation,
138
+ discoverMiniMaxKeys,
139
+ } from "./src/key-rotation.js";
140
+
141
+ // v0.5.0 — visual plan wiring: side-effect executor + plan-fs
142
+ import { executeSideEffect, type ExecuteOptions } from "./src/commands-impl.js";
143
+ import { join as pathJoin } from "node:path";
144
+ import { homedir } from "node:os";
145
+
146
+ // --- Env-var constants (per spec §8) -------------------------------------
147
+
148
+ /** `BIZAR_SERVE_PORT` — default 0 (random). */
149
+ function readServePort(): number {
150
+ const raw = process.env.BIZAR_SERVE_PORT;
151
+ if (raw === undefined || raw === "") return 0;
152
+ const n = Number(raw);
153
+ if (!Number.isFinite(n) || n < 0 || n > 65535) return 0;
154
+ return Math.floor(n);
155
+ }
156
+
157
+ /** `BIZAR_SERVE_DISABLE=1` disables the serve child entirely. */
158
+ function readServeDisabled(): boolean {
159
+ return process.env.BIZAR_SERVE_DISABLE === "1";
160
+ }
161
+
162
+ /** `BIZAR_MAX_CONCURRENT_INSTANCES` — default 8. */
163
+ function readMaxConcurrent(): number {
164
+ const raw = process.env.BIZAR_MAX_CONCURRENT_INSTANCES;
165
+ if (raw === undefined || raw === "") return 8;
166
+ const n = Number(raw);
167
+ if (!Number.isFinite(n) || n < 1) return 8;
168
+ return Math.floor(n);
169
+ }
170
+
171
+ /** `BIZAR_BACKGROUND_TOOL_CALL_CAP` — default 500. */
172
+ function readToolCallCap(): number {
173
+ const raw = process.env.BIZAR_BACKGROUND_TOOL_CALL_CAP;
174
+ if (raw === undefined || raw === "") return 500;
175
+ const n = Number(raw);
176
+ if (!Number.isFinite(n) || n < 1) return 500;
177
+ return Math.floor(n);
178
+ }
179
+
180
+ /**
181
+ * v0.3.0 — `BIZAR_STALL_TIMEOUT_MS` — default 180000 (3 min).
182
+ * Range [10000, 600000]; out-of-range falls back to default.
183
+ */
184
+ function readStallTimeoutMs(): number {
185
+ const raw = process.env.BIZAR_STALL_TIMEOUT_MS;
186
+ if (raw === undefined || raw === "") return 180_000;
187
+ const n = Number(raw);
188
+ if (!Number.isFinite(n) || n < 10_000) return 180_000;
189
+ return Math.min(Math.floor(n), 600_000);
190
+ }
191
+
192
+ /**
193
+ * v0.3.0 — `BIZAR_THINKING_LOOP_TIMEOUT_MS` — default 300000 (5 min).
194
+ * Range [30000, 900000]; out-of-range falls back to default.
195
+ */
196
+ function readThinkingLoopTimeoutMs(): number {
197
+ const raw = process.env.BIZAR_THINKING_LOOP_TIMEOUT_MS;
198
+ if (raw === undefined || raw === "") return 300_000;
199
+ const n = Number(raw);
200
+ if (!Number.isFinite(n) || n < 30_000) return 300_000;
201
+ return Math.min(Math.floor(n), 900_000);
202
+ }
203
+
204
+ /**
205
+ * v0.3.0 — `BIZAR_MAX_INTERVENTIONS` — default 1.
206
+ * Range [1, 3]; out-of-range falls back to default.
207
+ */
208
+ function readMaxInterventions(): number {
209
+ const raw = process.env.BIZAR_MAX_INTERVENTIONS;
210
+ if (raw === undefined || raw === "") return 1;
211
+ const n = Number(raw);
212
+ if (!Number.isFinite(n) || n < 1) return 1;
213
+ return Math.min(Math.floor(n), 3);
214
+ }
215
+
216
+ /** `BIZAR_HTTP_TIMEOUT_MS` — default 30000. */
217
+ function readHttpTimeoutMs(): number {
218
+ const raw = process.env.BIZAR_HTTP_TIMEOUT_MS;
219
+ if (raw === undefined || raw === "") return 30_000;
220
+ const n = Number(raw);
221
+ if (!Number.isFinite(n) || n < 1000) return 30_000;
222
+ return Math.floor(n);
223
+ }
224
+
225
+ // --- Shutdown coordination -----------------------------------------------
226
+
227
+ /** Module-level guard for SIGTERM/SIGINT reentry (spec §5.3). */
228
+ let shuttingDown = false;
229
+ /** Module-level handle to the InstanceManager for the signal handlers. */
230
+ let instanceManagerHandle: InstanceManager | null = null;
231
+ let serveHandle: ServeLifecycle | null = null;
232
+ let streamHandle: EventStream | null = null;
233
+ let loggerHandle: Logger | null = null;
234
+ const signalHandlerRefs = new Map<"SIGTERM" | "SIGINT", () => void>();
235
+
236
+ /** v0.6.2 — Set to `true` after the first time we wrap `globalThis.fetch`
237
+ * with the reasoning-clean wrapper. Subsequent calls in the same process
238
+ * are no-ops, so a plugin reload cannot double-wrap. */
239
+ let reasoningCleanInstalled = false;
240
+
241
+ /** v3.14.0 — Set to `true` after the first time we wrap `globalThis.fetch`
242
+ * with the key-rotation wrapper. Same idempotency pattern as reasoning-clean. */
243
+ let keyRotationInstalled = false;
244
+
245
+ /**
246
+ * v0.6.2 — Reasoning directive. Install the reasoning-clean fetch wrap
247
+ * on `globalThis.fetch`. The wrap strips inline ``...</think>` (and the
248
+ * other recognised variants — see `src/reasoning-clean.ts`) from
249
+ * chat-completions responses targeting `minimax`, while
250
+ * leaving the structured `reasoning` / `reasoning_details` fields
251
+ * intact.
252
+ *
253
+ * This is the workaround for the fact that opencode 1.17.9 does not
254
+ * fire the `config` hook in this runtime (the SDK type declares it, but
255
+ * the host never calls it). By the time the host would call `config`,
256
+ * the plugin would already be past init — and the AI SDK is already
257
+ * using the unwrapped fetch. So we wrap fetch once, globally, as the
258
+ * plugin initialises. Subsequent reloads in the same process are a
259
+ * no-op thanks to the `reasoningCleanInstalled` flag.
260
+ *
261
+ * MUST be installed BEFORE `installFetchKeyRotation` so the wrapper
262
+ * chain becomes: request → key-rotation → reasoning-clean → original.
263
+ * That way retries from key-rotation pass through reasoning-clean and
264
+ * the response body is transformed before being returned.
265
+ */
266
+ function installFetchReasoningCleanup(logger: Logger): void {
267
+ if (reasoningCleanInstalled) return;
268
+ const original = globalThis.fetch;
269
+ if (typeof original !== "function") {
270
+ logger.warn("bizar: globalThis.fetch is not a function; reasoning-clean wrap skipped");
271
+ return;
272
+ }
273
+ const wrapped = wrapFetchForReasoningCleanup(
274
+ original.bind(globalThis) as FetchLike,
275
+ {
276
+ debug: (msg) => logger.debug(msg),
277
+ },
278
+ );
279
+ globalThis.fetch = wrapped as typeof globalThis.fetch;
280
+ reasoningCleanInstalled = true;
281
+ logger.info("bizar: reasoning-clean fetch wrap installed (minimax)");
282
+ }
283
+
284
+ /**
285
+ * v3.14.0 — Multi-key rotation for the MiniMax provider. opencode has
286
+ * no built-in support for multiple API keys per provider; this wrapper
287
+ * closes that gap by reading N keys from env vars and rotating through
288
+ * them on 429 / 402 / 5xx responses. See `src/key-rotation.ts` for the
289
+ * retry policy.
290
+ *
291
+ * Env var resolution (`discoverMiniMaxKeys`):
292
+ * 1. `MINIMAX_API_KEYS` — comma-separated list
293
+ * 2. `MINIMAX_API_KEY` + `MINIMAX_API_KEY_2`, `_3`, ..., `_16`
294
+ *
295
+ * If fewer than 2 keys are configured, the wrapper is a pass-through
296
+ * no-op — single-key mode still works exactly as before.
297
+ *
298
+ * Install AFTER `installFetchReasoningCleanup` so this wrapper is the
299
+ * outermost layer: retries here re-enter reasoning-clean, so each
300
+ * attempt's response body is still cleaned before being returned.
301
+ */
302
+ function installFetchKeyRotation(logger: Logger): void {
303
+ if (keyRotationInstalled) return;
304
+ const original = globalThis.fetch;
305
+ if (typeof original !== "function") {
306
+ logger.warn("bizar: globalThis.fetch is not a function; key-rotation wrap skipped");
307
+ return;
308
+ }
309
+ const keys = discoverMiniMaxKeys();
310
+ if (keys.length < 2) {
311
+ keyRotationInstalled = true; // mark done so re-inits don't keep checking
312
+ logger.debug(
313
+ `bizar: ${keys.length} MiniMax key(s) found; key-rotation disabled (need >= 2)`,
314
+ );
315
+ return;
316
+ }
317
+ const wrapped = wrapFetchForKeyRotation(original.bind(globalThis) as FetchLike, {
318
+ providerId: "minimax",
319
+ apiKeys: keys,
320
+ debug: (msg) => logger.debug(msg),
321
+ });
322
+ globalThis.fetch = wrapped as typeof globalThis.fetch;
323
+ keyRotationInstalled = true;
324
+ logger.info(
325
+ `bizar: key-rotation fetch wrap installed (minimax, ${keys.length} keys)`,
326
+ );
327
+ }
328
+
329
+ // --- Plugin entry point ---------------------------------------------------
330
+
331
+ /**
332
+ * Runtime context shared across all hooks for one plugin instance. Created
333
+ * during init and closed over by every hook closure.
334
+ */
335
+ interface RuntimeContext {
336
+ logger: Logger;
337
+ options: NormalizedOptions;
338
+ envFlags: EnvFlags;
339
+ stateStore: StateStore;
340
+ settingsStore: SettingsStore;
341
+ logWriter: LogWriter;
342
+ worktree: string;
343
+ /** Project directory (often equal to `worktree`; the viewer / TUI
344
+ * may use this to display paths differently). v0.5.0 — the synthetic
345
+ * `ToolContext` built for slash-command tool invocations reads this. */
346
+ directory: string;
347
+ /** sessionID → set of message IDs already processed (spec §4.5). */
348
+ seenMessageIds: Map<string, Set<string>>;
349
+ /** sessionID → pending system-transform message, set at warn/escalate. */
350
+ pendingInjections: Map<string, string>;
351
+ /** v0.7.0-alpha.1 — Dashboard publisher (or null if disabled/not started).
352
+ * Used by the `event` hook to forward opencode session lifecycle
353
+ * events to the v2 dashboard via the @polderlabs/bizar-sdk. */
354
+ dashboardPublisher: DashboardPublisher | null;
355
+ }
356
+
357
+ /**
358
+ * Default-exported Plugin function. The whole body is wrapped in try/catch
359
+ * so that any initialization error logs via the SDK and returns empty
360
+ * hooks — opencode never crashes on a broken plugin (spec §8.1).
361
+ */
362
+ const plugin: Plugin = async (
363
+ input: PluginInput,
364
+ rawOptions?: PluginOptions,
365
+ ) => {
366
+ try {
367
+ return await init(input, rawOptions);
368
+ } catch (err) {
369
+ try {
370
+ const client = input.client as unknown as {
371
+ app?: { log?: (input: unknown) => unknown };
372
+ };
373
+ client.app?.log?.({
374
+ body: {
375
+ service: "bizar",
376
+ level: "error",
377
+ message: `bizar: init failed: ${err instanceof Error ? err.message : String(err)}`,
378
+ },
379
+ });
380
+ } catch {
381
+ // ignore — logging must never throw
382
+ }
383
+ return {};
384
+ }
385
+ };
386
+
387
+ export default plugin;
388
+
389
+ // --- Init ------------------------------------------------------------------
390
+
391
+ /**
392
+ * Initialize the plugin and return the hooks object. Separated from the
393
+ * top-level `plugin` function so the try/catch wrapper is unambiguous.
394
+ */
395
+ async function init(
396
+ input: PluginInput,
397
+ rawOptions?: PluginOptions,
398
+ ): Promise<Hooks> {
399
+ const envFlags = readEnvFlags();
400
+ const { options, notes } = normalizeOptions(rawOptions as never);
401
+
402
+ const logger = createLogger(input.client as unknown as Parameters<typeof createLogger>[0]);
403
+ loggerHandle = logger;
404
+
405
+ // §6.4 — refuse to start if logDir or stateDir is inside a secret dir.
406
+ const offending = findOffendingPath(options);
407
+ if (offending !== null) {
408
+ logger.error(
409
+ `bizar: refusing to start — logDir/stateDir ${offending.path} is inside a secret directory (${offending.kind}). Set BIZAR_DISABLE=1 or specify a different path.`,
410
+ );
411
+ return {};
412
+ }
413
+
414
+ // §6.5 — BIZAR_DISABLE=1 disables the plugin entirely.
415
+ if (envFlags.disable) {
416
+ logger.debug("bizar: disabled via BIZAR_DISABLE=1");
417
+ return {};
418
+ }
419
+
420
+ // Log any non-default normalization notes (spec §6.2).
421
+ for (const note of notes) {
422
+ logger.warn(`bizar: ${note}`);
423
+ }
424
+
425
+ // v0.6.2 — Reasoning directive. Wrap globalThis.fetch so that inline
426
+ // ``...</think>` blocks in chat completions responses
427
+ // from the minimax provider are stripped from `content` even
428
+ // when the model also emits structured reasoning. The `config` hook
429
+ // in the opencode plugin API is declared in the SDK type but does NOT
430
+ // fire in 1.17.9 (confirmed via debug probe 2026-06-24), so we wrap
431
+ // fetch globally as a fallback. Idempotent — only the first call in
432
+ // this process actually wraps.
433
+ installFetchReasoningCleanup(logger);
434
+ installFetchKeyRotation(logger);
435
+
436
+ const stateStore = new StateStore(options.stateDir, logger);
437
+ const settingsStore = new SettingsStore(options.stateDir, logger);
438
+ const logWriter = new LogWriter(options.logDir, options.logRotationBytes, logger);
439
+
440
+ // §4.6 — stale session cleanup (best-effort, on init).
441
+ try {
442
+ const validIds = await readValidSessionIds(input);
443
+ const deleted = await stateStore.cleanup(7, validIds);
444
+ if (deleted > 0) {
445
+ logger.info(`bizar: cleaned up ${deleted} stale session file(s)`);
446
+ }
447
+ } catch (err) {
448
+ logger.warn(
449
+ `bizar: stale session cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
450
+ );
451
+ }
452
+
453
+ // --- Background agents (v0.4.2) -----------------------------------------
454
+
455
+ let instanceManager: InstanceManager | null = null;
456
+ let serve: ServeLifecycle | null = null;
457
+ let stream: EventStream | null = null;
458
+ let dashboardPublisher: DashboardPublisher | null = null;
459
+ let bgAvailable = false;
460
+
461
+ if (readServeDisabled()) {
462
+ logger.info("bizar: background agents disabled via BIZAR_SERVE_DISABLE=1");
463
+ } else {
464
+ try {
465
+ const servePort = readServePort();
466
+ const bgStateStore = new BackgroundStateStore(options.stateDir, logger);
467
+ const backgroundStateCleanup = bgStateStore.cleanup(7).catch((err: unknown) => {
468
+ logger.warn(
469
+ `bizar: background state cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
470
+ );
471
+ return 0;
472
+ });
473
+ const maxConcurrent = readMaxConcurrent();
474
+ const toolCallCap = readToolCallCap();
475
+ const httpTimeoutMs = readHttpTimeoutMs();
476
+ const stallTimeoutMs = readStallTimeoutMs();
477
+ const thinkingLoopTimeoutMs = readThinkingLoopTimeoutMs();
478
+ const maxInterventions = readMaxInterventions();
479
+
480
+ serve = new ServeLifecycle({
481
+ port: servePort,
482
+ worktree: input.worktree,
483
+ logger,
484
+ });
485
+ serveHandle = serve;
486
+ const serveInfo = await serve.start();
487
+ // v3.5.7 — Persist serve-info so the dashboard can talk to us
488
+ try {
489
+ writeServeInfo(options.stateDir, {
490
+ baseUrl: serveInfo.baseUrl,
491
+ port: serveInfo.port,
492
+ password: serveInfo.password,
493
+ worktree: serveInfo.worktree,
494
+ pid: serveInfo.pid,
495
+ startedAt: serveInfo.startedAt,
496
+ }, logger);
497
+ logger.info(`[bizar] wrote serve-info to ${options.stateDir}/serve.json`);
498
+ } catch (err) {
499
+ logger.warn(`[bizar] failed to write serve-info: ${err instanceof Error ? err.message : String(err)}`);
500
+ }
501
+ const http = new HttpClient({
502
+ baseUrl: `http://127.0.0.1:${serveInfo.port}`,
503
+ password: serveInfo.password,
504
+ logger,
505
+ timeoutMs: httpTimeoutMs,
506
+ });
507
+ const authHeader = `Basic ${btoa(`opencode:${serveInfo.password}`)}`;
508
+ stream = new EventStream({
509
+ baseUrl: `http://127.0.0.1:${serveInfo.port}`,
510
+ directory: input.worktree,
511
+ authHeader,
512
+ logger,
513
+ http,
514
+ });
515
+ streamHandle = stream;
516
+
517
+ // v0.7.0-alpha.1 — Wire dashboard publisher to the EventStream so
518
+ // every opencode SSE event is also published to the v2 dashboard.
519
+ // The publisher gracefully degrades if the dashboard is unreachable
520
+ // (queues, retries, warns; never throws into the plugin).
521
+ try {
522
+ const pub = createDashboardPublisher({
523
+ logger,
524
+ });
525
+ await pub.start();
526
+ dashboardPublisher = pub;
527
+ stream.onEvent((event) => {
528
+ // Translate opencode StreamEvent → DashboardEvent shape.
529
+ // The dashboard only cares about the wire-level (type, properties)
530
+ // so we forward { type, properties } directly. Cast through
531
+ // `unknown` because the opencode event shape doesn't exactly
532
+ // match the SDK's discriminated DashboardEvent — it's a
533
+ // forward-compatible passthrough.
534
+ const dashEvent = {
535
+ type: event.type,
536
+ properties: { ...event },
537
+ } as unknown as Parameters<DashboardPublisher["publish"]>[0];
538
+ void pub.publish(dashEvent);
539
+ });
540
+ // Stop the publisher when the stream is disposed.
541
+ const origDisconnect = stream.disconnect.bind(stream);
542
+ stream.disconnect = async () => {
543
+ await origDisconnect();
544
+ pub.stop();
545
+ };
546
+ logger.info("bizar: dashboard publisher wired to EventStream (v2 protocol)");
547
+ } catch (err) {
548
+ logger.warn(
549
+ `bizar: dashboard publisher failed to start: ${
550
+ err instanceof Error ? err.message : String(err)
551
+ }`,
552
+ );
553
+ }
554
+
555
+ instanceManager = new InstanceManager({
556
+ stateStore: bgStateStore,
557
+ maxConcurrent,
558
+ toolCallCap,
559
+ logger,
560
+ worktree: input.worktree,
561
+ serve,
562
+ http,
563
+ stream,
564
+ stallTimeoutMs,
565
+ thinkingLoopTimeoutMs,
566
+ maxInterventions,
567
+ });
568
+ instanceManagerHandle = instanceManager;
569
+
570
+ // §5.4 — rebuild in-memory map from disk.
571
+ await instanceManager.rebuildInMemoryMap();
572
+
573
+ // §5.2 — crash-recovery handler.
574
+ serve.onUnexpectedExit(() => {
575
+ if (!instanceManager) return;
576
+ void (async () => {
577
+ try {
578
+ await instanceManager.shutdownAll();
579
+ } catch (err: unknown) {
580
+ logger.warn(
581
+ `bizar: shutdownAll on serve crash failed: ${
582
+ err instanceof Error ? err.message : String(err)
583
+ }`,
584
+ );
585
+ }
586
+ })();
587
+ });
588
+
589
+ // Open the SSE connection (best-effort — failure does not abort init).
590
+ try {
591
+ await stream.connect();
592
+ bgAvailable = true;
593
+ } catch (err: unknown) {
594
+ logger.warn(
595
+ `bizar: SSE connection failed: ${err instanceof Error ? err.message : String(err)}; background agents will still accept new spawns (will reconnect on demand)`,
596
+ );
597
+ }
598
+
599
+ // Surface the cleanup result.
600
+ const deletedBg = await backgroundStateCleanup;
601
+ if (deletedBg > 0) {
602
+ logger.info(`bizar: cleaned up ${deletedBg} stale background state file(s)`);
603
+ }
604
+
605
+ logger.info(
606
+ `bizar: background agents ready (port=${serveInfo.port}, cap=${maxConcurrent}, toolCallCap=${toolCallCap}, stallTimeoutMs=${stallTimeoutMs}, thinkingLoopTimeoutMs=${thinkingLoopTimeoutMs}, maxInterventions=${maxInterventions})`,
607
+ );
608
+ } catch (err: unknown) {
609
+ logger.warn(
610
+ `bizar: background agents unavailable: ${err instanceof Error ? err.message : String(err)}`,
611
+ );
612
+ // Leave `bgAvailable = false`. The tools will return a clear error.
613
+ }
614
+ }
615
+
616
+ // --- Signal traps (spec §5.3) ------------------------------------------
617
+
618
+ installSignalHandlers(logger, instanceManager, serve, stream, options.stateDir);
619
+
620
+ const ctx: RuntimeContext = {
621
+ logger,
622
+ options,
623
+ envFlags,
624
+ stateStore,
625
+ settingsStore,
626
+ logWriter,
627
+ worktree: input.worktree,
628
+ directory: input.directory,
629
+ seenMessageIds: new Map(),
630
+ pendingInjections: new Map(),
631
+ dashboardPublisher,
632
+ };
633
+
634
+ return buildHooks(ctx, { instanceManager, bgAvailable });
635
+ }
636
+
637
+ // --- Signal handling (spec §5.3) -----------------------------------------
638
+
639
+ function installSignalHandlers(
640
+ logger: Logger,
641
+ instanceManager: InstanceManager | null,
642
+ serve: ServeLifecycle | null,
643
+ stream: EventStream | null,
644
+ stateDir: string,
645
+ ): void {
646
+ const onSignal = async (sig: "SIGTERM" | "SIGINT") => {
647
+ if (shuttingDown) return;
648
+ shuttingDown = true;
649
+ logger.warn(`bizar: received ${sig}; shutting down`);
650
+
651
+ // 1. Mark all in-memory instances as failed (spec §5.3 step 1).
652
+ if (instanceManager !== null) {
653
+ try {
654
+ await instanceManager.shutdownAll();
655
+ } catch (err: unknown) {
656
+ logger.warn(
657
+ `bizar: shutdownAll on ${sig} failed: ${
658
+ err instanceof Error ? err.message : String(err)
659
+ }`,
660
+ );
661
+ }
662
+ }
663
+
664
+ // 2. Close SSE.
665
+ if (stream !== null) {
666
+ try {
667
+ await stream.disconnect();
668
+ } catch {
669
+ // ignore
670
+ }
671
+ }
672
+
673
+ // 3. Kill serve child.
674
+ if (serve !== null) {
675
+ try {
676
+ await serve.stop();
677
+ } catch (err: unknown) {
678
+ logger.warn(
679
+ `bizar: serve.stop on ${sig} failed: ${
680
+ err instanceof Error ? err.message : String(err)
681
+ }`,
682
+ );
683
+ }
684
+ }
685
+
686
+ // 4. Clear serve-info so the dashboard doesn't try to talk to a dead serve.
687
+ clearServeInfo(stateDir, logger);
688
+
689
+ // 5. Exit. (Note: the host may keep the process alive if other work
690
+ // is pending, but for the plugin process this is the end.)
691
+ try {
692
+ process.exit(0);
693
+ } catch {
694
+ // process.exit may not be available in all environments; ignore.
695
+ }
696
+ };
697
+
698
+ // Idempotent registration — if the plugin is reloaded, we don't want
699
+ // duplicate handlers. Use `process.once` so each handler runs at most
700
+ // once per signal; the `shuttingDown` guard catches reentry.
701
+ for (const sig of ["SIGTERM", "SIGINT"] as const) {
702
+ const previous = signalHandlerRefs.get(sig);
703
+ if (previous) {
704
+ try {
705
+ process.removeListener(sig, previous);
706
+ } catch {
707
+ // ignore
708
+ }
709
+ }
710
+ const handler = () => {
711
+ void onSignal(sig);
712
+ };
713
+ signalHandlerRefs.set(sig, handler);
714
+ process.once(sig, handler);
715
+ }
716
+ }
717
+
718
+ // --- Init helpers ---------------------------------------------------------
719
+
720
+ /**
721
+ * Race a promise against a timeout. Returns the promise's value if it
722
+ * resolves in time; throws a labeled `Error` otherwise.
723
+ *
724
+ * The original promise is intentionally NOT cancelled (we don't have
725
+ * an `AbortSignal` to pass to the opencode client). If the underlying
726
+ * call eventually rejects after we've already returned, the caller
727
+ * should attach a no-op `.catch(() => undefined)` to suppress the
728
+ * unhandled-rejection warning.
729
+ *
730
+ * v0.5.2: extracted from `readValidSessionIds` so it can be unit
731
+ * tested in isolation. See `tests/init-helpers.test.ts`.
732
+ */
733
+ export async function withTimeout<T>(
734
+ promise: Promise<T>,
735
+ timeoutMs: number,
736
+ label: string,
737
+ ): Promise<T> {
738
+ let timer: ReturnType<typeof setTimeout> | undefined;
739
+ const timeoutPromise = new Promise<never>((_, reject) => {
740
+ timer = setTimeout(
741
+ () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
742
+ timeoutMs,
743
+ );
744
+ });
745
+ try {
746
+ return await Promise.race([promise, timeoutPromise]);
747
+ } finally {
748
+ if (timer !== undefined) clearTimeout(timer);
749
+ }
750
+ }
751
+
752
+ // --- Hooks ----------------------------------------------------------------
753
+
754
+ /**
755
+ * Best-effort read of valid session IDs from opencode. If `client.session`
756
+ * is unavailable or the call fails or times out, return an empty set —
757
+ * the age-based branch of the cleanup still runs (spec §4.6).
758
+ *
759
+ * v0.5.2 FIX (postmortem 2026-06-18, Layer 1): the previous version
760
+ * called `client.session.list()` with no timeout. If the session
761
+ * store was slow, busy, or in a broken state, the call would hang
762
+ * forever, blocking the plugin's `init()` and stalling the UI on a
763
+ * blank screen. We now race the call against a 1-second timeout and
764
+ * fall back to an empty set on timeout.
765
+ */
766
+ export async function readValidSessionIds(input: PluginInput): Promise<Set<string>> {
767
+ try {
768
+ const client = input.client as unknown as {
769
+ session?: { list?: () => Promise<{ data?: Array<{ id: string }> } | Array<{ id: string }>> };
770
+ };
771
+ if (!client.session || typeof client.session.list !== "function") {
772
+ return new Set();
773
+ }
774
+ // Suppress unhandled rejection if the call eventually rejects after
775
+ // the timeout has already fired (see `withTimeout` note above).
776
+ const listPromise = client.session.list();
777
+ listPromise.catch(() => undefined);
778
+ const result = await withTimeout(
779
+ listPromise,
780
+ 1000,
781
+ "client.session.list",
782
+ );
783
+ const list = Array.isArray(result) ? result : (result.data ?? []);
784
+ return new Set(list.map((s) => s.id));
785
+ } catch {
786
+ return new Set();
787
+ }
788
+ }
789
+
790
+ interface BgDeps {
791
+ instanceManager: InstanceManager | null;
792
+ bgAvailable: boolean;
793
+ }
794
+
795
+ // --- Slash-command helpers (v0.4.0) ------------------------------------
796
+
797
+ /**
798
+ * Read the user-typed text from a `chat.message` hook output.
799
+ *
800
+ * `output.parts` is a discriminated union (`Part[]`). We concatenate any
801
+ * TextPart entries. Other part types (file, tool, etc.) are skipped.
802
+ *
803
+ * Returns `null` if no text could be extracted (e.g. the message is a
804
+ * file-only attachment, or the parts array is missing/malformed).
805
+ */
806
+ function readMessageText(
807
+ output: { message?: unknown; parts?: unknown } | undefined,
808
+ ): string | null {
809
+ if (!output || !Array.isArray(output.parts)) return null;
810
+ const parts = output.parts as Array<{ type?: string; text?: string }>;
811
+ const fragments: string[] = [];
812
+ for (const part of parts) {
813
+ if (part && part.type === "text" && typeof part.text === "string") {
814
+ fragments.push(part.text);
815
+ }
816
+ }
817
+ const joined = fragments.join("\n").trim();
818
+ return joined === "" ? null : joined;
819
+ }
820
+
821
+ /**
822
+ * List the slugs of plans in the worktree's `plans/` directory.
823
+ *
824
+ * Pure best-effort: returns `[]` on missing dir, read errors, or any
825
+ * I/O exception. The slash-command parser uses this only for the
826
+ * `/plan list` response, so a missing list should not throw.
827
+ */
828
+ async function listPlanSlugs(worktree: string, logger: Logger): Promise<string[]> {
829
+ try {
830
+ const { readdirSync, statSync } = await import("node:fs");
831
+ const { join } = await import("node:path");
832
+ const plansDir = join(worktree, "plans");
833
+ let entries: string[];
834
+ try {
835
+ entries = readdirSync(plansDir);
836
+ } catch {
837
+ return [];
838
+ }
839
+ const slugs: string[] = [];
840
+ for (const name of entries) {
841
+ try {
842
+ const stat = statSync(join(plansDir, name));
843
+ if (stat.isDirectory()) slugs.push(name);
844
+ } catch {
845
+ // skip unreadable entries
846
+ }
847
+ }
848
+ slugs.sort();
849
+ return slugs;
850
+ } catch (err: unknown) {
851
+ logger.debug(
852
+ `bizar: listPlanSlugs failed: ${err instanceof Error ? err.message : String(err)}`,
853
+ );
854
+ return [];
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Build the hooks object. Each hook is a small async function that
860
+ * delegates to the runtime context and the supporting modules.
861
+ */
862
+ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
863
+ // ────────────────────────────────────────────────────────────────────
864
+ // v0.6.2 — Reasoning directive
865
+ // ────────────────────────────────────────────────────────────────────
866
+ // Some reasoning models (notably MiniMax M-series models) emit
867
+ // their chain-of-thought in BOTH the structured `reasoning` /
868
+ // `reasoning_details` field AND inline as `` blocks inside
869
+ // `message.content`. opencode's MiniMax provider SDK extracts the structured
870
+ // reasoning correctly and renders it as a separate "Thought" panel,
871
+ // but it does NOT strip the inline blocks from `content`, so the user
872
+ // sees the same thinking text twice — once in the proper panel and
873
+ // again as visible message text below it.
874
+ //
875
+ // Defence in depth (three layers, in order of impact):
876
+ //
877
+ // 1. `installFetchReasoningCleanup` (init-time) — wraps
878
+ // `globalThis.fetch` with `wrapFetchForReasoningCleanup` from
879
+ // `src/reasoning-clean.ts`. The wrap strips the inline ``
880
+ // blocks from chat-completions responses to the `minimax` provider while leaving the structured reasoning fields
881
+ // alone. This is the only layer that fixes the CURRENT
882
+ // response in-flight. The opencode plugin API in 1.17.9 declares
883
+ // a `config` hook in the SDK type but does not actually fire it
884
+ // (confirmed via debug probe 2026-06-24), so we wrap fetch
885
+ // globally instead.
886
+ //
887
+ // 2. `experimental.chat.system.transform` — runs every turn; we
888
+ // push a directive telling the model to put thinking in the
889
+ // structured field only.
890
+ //
891
+ // 3. `experimental.chat.messages.transform` — runs before each
892
+ // request; we strip `` blocks from previous assistant
893
+ // messages so the model sees clean history and is less likely
894
+ // to keep emitting inline ``.
895
+ //
896
+ // Layers 2 and 3 reduce the frequency of the leak; layer 1 strips
897
+ // any leak that still slips through.
898
+ const REASONING_DIRECTIVE_MARKER = "BIZAR_REASONING_DIRECTIVE_v0.6.2";
899
+ const REASONING_DIRECTIVE = [
900
+ REASONING_DIRECTIVE_MARKER,
901
+ "",
902
+ "When reasoning is enabled for this conversation, output your thinking",
903
+ "ONLY in the model's structured reasoning field. Do NOT emit `` blocks",
904
+ "inline inside your message content — the opencode host extracts the",
905
+ "reasoning field and renders it as a separate, collapsable \"Thought\"",
906
+ "panel. If you also emit the same text inline, the user will see your",
907
+ "thinking twice (once in the panel and once as visible message body).",
908
+ "Keep the actual response text in the normal content stream.",
909
+ ].join(" ");
910
+
911
+ // Build the 7 tools. We always register them; if the serve child is
912
+ // not available, the background tools return a clear error. The
913
+ // bizar_get_plan_comments, bizar_plan_action, and
914
+ // bizar_wait_for_feedback tools only need the worktree, so they
915
+ // work regardless of the serve child's state.
916
+ //
917
+ // v0.4.0 — added `bizar_plan_action` (CRUD on the v2 canvas) and
918
+ // bizar_wait_for_feedback (poll until feedback). Both are pure
919
+ // file I/O — no serve child required.
920
+ //
921
+ // v0.5.0 — renamed `bizarre_*` → `bizar_*` (single `r`) to match
922
+ // the docs and `config/opencode.json`. The earlier typo silently
923
+ // disabled the plan tools at runtime; this fix brings the registry
924
+ // in sync.
925
+ const basePlanTools = {
926
+ bizar_get_plan_comments: createBgGetCommentsTool({
927
+ worktree: ctx.worktree,
928
+ logger: ctx.logger,
929
+ }),
930
+ bizar_plan_action: createPlanActionTool({
931
+ worktree: ctx.worktree,
932
+ logger: ctx.logger,
933
+ }),
934
+ bizar_wait_for_feedback: createWaitForFeedbackTool({
935
+ worktree: ctx.worktree,
936
+ logger: ctx.logger,
937
+ }),
938
+ // v3.22.0 — read the structured feedback.md file the user writes
939
+ // when they click "Submit to agent" on a glyph. Available to all
940
+ // agents; read-only.
941
+ read_glyph_feedback: createReadGlyphFeedbackTool({
942
+ worktree: ctx.worktree,
943
+ logger: ctx.logger,
944
+ }),
945
+ // v4.0.0 — open the Bizar Memory vault in Obsidian. Available to
946
+ // all agents; no serve child required.
947
+ bizar_open_kb: createOpenKbTool({
948
+ worktree: ctx.worktree,
949
+ logger: ctx.logger,
950
+ }),
951
+ };
952
+ const tools = bg.instanceManager
953
+ ? {
954
+ ...basePlanTools,
955
+ // v0.8.0 — bg-spawn no longer needs the HTTP client. It
956
+ // spawns an `opencode run` subprocess per agent (see
957
+ // src/opencode-runner.ts). The serve child is still
958
+ // available for the dashboard's v2 protocol and for any
959
+ // TUI/web client that wants to attach to it.
960
+ bizar_spawn_background: createBgSpawnTool({
961
+ instanceManager: bg.instanceManager,
962
+ worktree: ctx.worktree,
963
+ logger: ctx.logger,
964
+ }),
965
+ bizar_status: createBgStatusTool({
966
+ instanceManager: bg.instanceManager,
967
+ logger: ctx.logger,
968
+ }),
969
+ bizar_collect: createBgCollectTool({
970
+ instanceManager: bg.instanceManager,
971
+ logger: ctx.logger,
972
+ }),
973
+ bizar_kill: createBgKillTool({
974
+ instanceManager: bg.instanceManager,
975
+ logger: ctx.logger,
976
+ }),
977
+ }
978
+ : {
979
+ ...basePlanTools,
980
+ ...bgDisabledTools(ctx.logger),
981
+ };
982
+
983
+ return {
984
+ // Push a persistent system-prompt directive that tells reasoning
985
+ // models to put their thinking in the structured reasoning field
986
+ // (rendered as a separate "Thought" panel by opencode) rather than
987
+ // also emitting it inline as `` blocks in the content. The MiniMax direct
988
+ // SDK does not strip the inline `` blocks, so without this
989
+ // directive the user sees the reasoning twice — once in the proper
990
+ // panel and once as visible message text.
991
+ //
992
+ // We push the directive only when the marker is absent so we don't
993
+ // append it on every turn.
994
+ "experimental.chat.system.transform": async (input, output) => {
995
+ const sessionID = input.sessionID;
996
+ if (!sessionID) return;
997
+ if (!output.system.some((s) => s.includes(REASONING_DIRECTIVE_MARKER))) {
998
+ output.system.push(REASONING_DIRECTIVE);
999
+ }
1000
+ // §3.1, §5.4 — handoff injection point. We push a single string
1001
+ // onto `output.system` if a pending injection is queued for this
1002
+ // session.
1003
+ const pending = ctx.pendingInjections.get(sessionID);
1004
+ if (pending) {
1005
+ output.system.push(pending);
1006
+ ctx.pendingInjections.delete(sessionID);
1007
+ }
1008
+ },
1009
+
1010
+ // Before the model is called, strip `` blocks from
1011
+ // any text content in previous assistant messages. This keeps the
1012
+ // model's view of its own history clean of the duplicated thinking
1013
+ // it emitted earlier, reducing the chance it will keep emitting
1014
+ // inline `` on subsequent turns.
1015
+ "experimental.chat.messages.transform": async (_input, output) => {
1016
+ try {
1017
+ const messages = (output as { messages?: unknown }).messages;
1018
+ if (!Array.isArray(messages)) return;
1019
+ for (const msg of messages) {
1020
+ if (!msg || typeof msg !== "object") continue;
1021
+ const m = msg as { role?: unknown; parts?: unknown };
1022
+ if (m.role !== "assistant") continue;
1023
+ if (!Array.isArray(m.parts)) continue;
1024
+ for (const part of m.parts) {
1025
+ if (!part || typeof part !== "object") continue;
1026
+ const p = part as { type?: unknown; text?: unknown };
1027
+ if (p.type === "text" && typeof p.text === "string" && p.text.includes("<think>")) {
1028
+ p.text = stripInlineThinkBlocks(p.text);
1029
+ }
1030
+ }
1031
+ }
1032
+ } catch (err) {
1033
+ ctx.logger.warn(
1034
+ `bizar: messages.transform failed (passing through): ${
1035
+ err instanceof Error ? err.message : String(err)
1036
+ }`,
1037
+ );
1038
+ }
1039
+ },
1040
+
1041
+ // v0.6.2 — Reasoning directive. Strip inline `` blocks
1042
+ // from the FINAL text of each completed assistant text part. This is
1043
+ // the post-processing layer that fixes the CURRENT response in cases
1044
+ // where the model emits its chain-of-thought in BOTH the structured
1045
+ // `reasoning` field AND inline in `content` (the MiniMax-direct
1046
+ // leak). opencode's MiniMax provider SDK does not strip the inline blocks,
1047
+ // so we do it here at the boundary between the SDK output and the
1048
+ // UI rendering. The `config` hook that the SDK type declares for
1049
+ // fetch-level wrapping does NOT fire in 1.17.9, and the AI SDK
1050
+ // uses `Bun.fetch` (read-only) rather than `globalThis.fetch`, so a
1051
+ // fetch wrap is a no-op in this runtime. `experimental.text.complete`
1052
+ // is the working alternative — it runs on every completed text
1053
+ // part, with mutable `output.text`. Idempotent: stripping already-
1054
+ // cleaned text is a no-op.
1055
+ "experimental.text.complete": async (input, output) => {
1056
+ try {
1057
+ const original = output.text;
1058
+ if (typeof original !== "string" || !original.includes("<think>")) return;
1059
+ const cleaned = stripInlineThinkBlocks(original);
1060
+ if (cleaned !== original) {
1061
+ output.text = cleaned;
1062
+ ctx.logger.debug(
1063
+ `bizar: text.complete stripped think blocks (session=${input.sessionID} message=${input.messageID} part=${input.partID} ${original.length}→${cleaned.length}B)`,
1064
+ );
1065
+ }
1066
+ } catch (err) {
1067
+ ctx.logger.warn(
1068
+ `bizar: text.complete failed (passing through): ${
1069
+ err instanceof Error ? err.message : String(err)
1070
+ }`,
1071
+ );
1072
+ }
1073
+ },
1074
+
1075
+ // §3.1, §4.5.1 — event: track session boundaries. We do NOT create
1076
+ // the state file here (canonical lifecycle: file is created at the
1077
+ // `chat.message` seed, per spec §4.5.1).
1078
+ event: async ({ event }) => {
1079
+ try {
1080
+ // v0.7.0-alpha.1 — opencode's event object has { type: string,
1081
+ // properties: { sessionID: string, ... } }. The legacy plugin
1082
+ // assumed `event.sessionID` was top-level (which is wrong), so
1083
+ // the hook returned early for every event. We extract from
1084
+ // BOTH locations to be robust across opencode versions, and
1085
+ // publish to the dashboard regardless.
1086
+ const ev = event as {
1087
+ type?: string;
1088
+ sessionID?: string;
1089
+ properties?: { sessionID?: string; [k: string]: unknown };
1090
+ };
1091
+ const type = ev.type;
1092
+ const sessionID = ev.sessionID ?? ev.properties?.sessionID;
1093
+ if (!type) return;
1094
+
1095
+ // v0.7.0-alpha.1 — Forward every opencode event to the dashboard.
1096
+ // The plugin SDK does NOT translate opencode events to the SDK's
1097
+ // discriminated DashboardEvent shape (it's a forward-compatible
1098
+ // passthrough — the dashboard is happy with any {type, properties}
1099
+ // event object). The publish is fire-and-forget; failures are
1100
+ // logged and swallowed inside the publisher.
1101
+ if (ctx.dashboardPublisher !== null) {
1102
+ const dashEvent = {
1103
+ type,
1104
+ properties: { ...ev },
1105
+ } as unknown as Parameters<DashboardPublisher["publish"]>[0];
1106
+ void ctx.dashboardPublisher.publish(dashEvent);
1107
+ }
1108
+
1109
+ // Legacy logic below: only runs when we have a sessionID.
1110
+ if (!sessionID) return;
1111
+
1112
+ if (type === "session.deleted") {
1113
+ await ctx.stateStore.withLock(sessionID, async () => {
1114
+ await ctx.stateStore.delete(sessionID);
1115
+ });
1116
+ ctx.pendingInjections.delete(sessionID);
1117
+ ctx.seenMessageIds.delete(sessionID);
1118
+ }
1119
+ // Other event types are no-ops on the hook side. The state file
1120
+ // is updated by `chat.message` and `tool.execute.before/after`.
1121
+ } catch (err) {
1122
+ ctx.logger.warn(
1123
+ `bizar: event hook error: ${err instanceof Error ? err.message : String(err)}`,
1124
+ );
1125
+ }
1126
+ },
1127
+
1128
+ // §4.5 — seed session state on first user message per session.
1129
+ // The hook key is the literal string "chat.message" (with a dot) per
1130
+ // the opencode plugin API.
1131
+ //
1132
+ // §v4.1 (v0.4.0) — Slash command detection happens FIRST, before the
1133
+ // existing state-seeding logic. If the user typed a slash command we:
1134
+ // 1. Apply any settings patch via `SettingsStore`.
1135
+ // 2. Execute the side-effect (v0.5.0 — previously silently dropped).
1136
+ // 3. Throw the response text. The host (TUI/CLI) surfaces it to
1137
+ // the user; the LLM sees it on the next turn as a tool error.
1138
+ //
1139
+ // We chose throw-over-mutate because:
1140
+ // - Throwing is the same pattern `tool.execute.before` uses for
1141
+ // loop-detection blocks (see §5.4). It's well-tested in production.
1142
+ // - Mutating `output.parts` / `output.message` is brittle — the
1143
+ // shapes differ between opencode versions, and the host may not
1144
+ // honor a synthetic `text` part from a hook.
1145
+ "chat.message": async (input, output) => {
1146
+ const sessionID = input.sessionID;
1147
+ const messageID = input.messageID;
1148
+ const agent = input.agent;
1149
+ if (!sessionID) return;
1150
+
1151
+ // --- v0.4.0: slash command detection -----------------------------
1152
+ // Runs before the disableLoop/disableLog check — slash commands
1153
+ // should work even when loop detection / logging is off.
1154
+ try {
1155
+ const messageText = readMessageText(output);
1156
+ if (messageText !== null) {
1157
+ const currentSettings = await ctx.settingsStore.get();
1158
+ const availableSlugs = await listPlanSlugs(ctx.worktree, ctx.logger);
1159
+ const result = parseSlashCommand(messageText, {
1160
+ currentSettings,
1161
+ availablePlanSlugs: availableSlugs,
1162
+ defaultPort: 4321,
1163
+ });
1164
+ if (result !== null) {
1165
+ if (result.settingsPatch) {
1166
+ await ctx.settingsStore.update(result.settingsPatch);
1167
+ }
1168
+ // --- v0.5.0: execute the side-effect (was silently dropped
1169
+ // in v0.4.0). The executor returns an optional
1170
+ // override/suffix that replaces/appends the
1171
+ // parser's response. Tool invocations build a
1172
+ // synthetic ToolContext and pre-validate args.
1173
+ let finalResponse = result.response;
1174
+ if (result.sideEffect !== undefined) {
1175
+ const execOpts: ExecuteOptions = {
1176
+ tools,
1177
+ defaultTemplate: currentSettings.defaultTemplate,
1178
+ defaultPort: 4321,
1179
+ };
1180
+ try {
1181
+ const exec = await executeSideEffect(
1182
+ result.sideEffect,
1183
+ {
1184
+ worktree: ctx.worktree,
1185
+ directory: ctx.directory,
1186
+ logger: ctx.logger,
1187
+ },
1188
+ execOpts,
1189
+ );
1190
+ if (exec.responseOverride !== undefined) {
1191
+ finalResponse = exec.responseOverride;
1192
+ } else if (exec.responseSuffix !== undefined) {
1193
+ finalResponse = `${result.response}${exec.responseSuffix}`;
1194
+ }
1195
+ } catch (execErr: unknown) {
1196
+ // Defense-in-depth — `executeSideEffect` already catches
1197
+ // its own errors, but if it ever throws (e.g. a bug in
1198
+ // a future handler) we stringify into the response
1199
+ // rather than crashing the chat hook.
1200
+ const msg =
1201
+ execErr instanceof Error ? execErr.message : String(execErr);
1202
+ ctx.logger.warn(`bizar: side-effect crashed: ${msg}`);
1203
+ finalResponse = `Command failed: ${msg}`;
1204
+ }
1205
+ }
1206
+ // --- v0.5.1: dialog support.
1207
+ // If the command emitted a dialog descriptor, persist it to disk
1208
+ // so Tyr's dialog-poller can broadcast it to the dashboard.
1209
+ // We return without throwing so no chat bubble is shown.
1210
+ if (result.dialog) {
1211
+ // S4 — defense-in-depth: validate ID shape before touching disk.
1212
+ if (!/^dlg_[a-zA-Z0-9_-]{1,64}$/.test(result.dialog.id)) {
1213
+ return; // silently drop malformed dialog IDs
1214
+ }
1215
+ try {
1216
+ const { mkdir, writeFile } = await import("node:fs/promises");
1217
+ const dialogDir = pathJoin(homedir(), ".cache", "bizar", "dialogs");
1218
+ await mkdir(dialogDir, { recursive: true });
1219
+ await writeFile(
1220
+ pathJoin(dialogDir, `${result.dialog.id}.json`),
1221
+ JSON.stringify({ ...result.dialog, createdAt: new Date().toISOString() }, null, 2),
1222
+ );
1223
+ } catch (dialogErr: unknown) {
1224
+ const msg = dialogErr instanceof Error ? dialogErr.message : String(dialogErr);
1225
+ ctx.logger.warn(`bizar: failed to write dialog file: ${msg}`);
1226
+ }
1227
+ return;
1228
+ }
1229
+ // Surface the response to the user/host. We throw so the
1230
+ // message is treated as handled; the LLM does not process
1231
+ // it further. The host renders the throw message.
1232
+ throw new Error(finalResponse);
1233
+ }
1234
+ }
1235
+ } catch (err) {
1236
+ // Re-throw — if it's our slash-command response, propagate it.
1237
+ // If it's an unexpected I/O error, log and fall through.
1238
+ if (err instanceof Error && err.message !== "" && err.message !== undefined) {
1239
+ // Heuristic: errors we throw ourselves contain a non-technical
1240
+ // response (starts with one of the canonical prefixes OR is
1241
+ // simply a human-readable sentence). Errors from I/O contain
1242
+ // "ENOENT", "EACCES", etc. We always re-throw errors that the
1243
+ // parser produced (response starts with known prefixes or
1244
+ // doesn't contain a colon+code pattern).
1245
+ const msg = err.message;
1246
+ const looksLikeIoError = /(ENOENT|EACCES|EROFS|EISDIR|EPERM|Error:)/.test(msg);
1247
+ if (!looksLikeIoError) {
1248
+ throw err;
1249
+ }
1250
+ }
1251
+ ctx.logger.warn(
1252
+ `bizar: slash-command handling failed: ${err instanceof Error ? err.message : String(err)}`,
1253
+ );
1254
+ // Fall through to normal state seeding.
1255
+ }
1256
+
1257
+ // --- v0.3.0: state seeding ----------------------------------------
1258
+ if (ctx.envFlags.disableLoop && ctx.envFlags.disableLog) return;
1259
+
1260
+ // Dedupe by message ID (spec §4.5).
1261
+ if (messageID) {
1262
+ let seen = ctx.seenMessageIds.get(sessionID);
1263
+ if (!seen) {
1264
+ seen = new Set();
1265
+ ctx.seenMessageIds.set(sessionID, seen);
1266
+ }
1267
+ if (seen.has(messageID)) return; // duplicate event — no-op
1268
+ seen.add(messageID);
1269
+ }
1270
+
1271
+ // Seed parentAgent and create the state file on the first
1272
+ // message per session (spec §4.5.1 — canonical lifecycle).
1273
+ await ctx.stateStore.withLock(sessionID, async () => {
1274
+ const existing = await ctx.stateStore.load(sessionID);
1275
+ if (existing.parentAgent !== null) return; // already seeded
1276
+ const now = Date.now();
1277
+ const seeded: SessionState = {
1278
+ sessionId: sessionID,
1279
+ parentAgent: agent ?? null,
1280
+ startedAt: now,
1281
+ lastActivityAt: now,
1282
+ turnCount: 1,
1283
+ toolCalls: [],
1284
+ warningsIssued: 0,
1285
+ blocksTriggered: 0,
1286
+ };
1287
+ await ctx.stateStore.save(seeded);
1288
+ ctx.logger.debug(`bizar: seeded session ${sessionID} with parentAgent=${seeded.parentAgent}`);
1289
+ });
1290
+ },
1291
+
1292
+ // §3.1, §5.1, §5.4 — primary loop-detection point.
1293
+ "tool.execute.before": async (input, output) => {
1294
+ if (ctx.envFlags.disableLoop) return;
1295
+ const sessionID = input.sessionID;
1296
+ const tool = input.tool;
1297
+ if (!sessionID || !tool) return;
1298
+
1299
+ // Compute fingerprint of (tool, args). args is mutable in the
1300
+ // hook output; we read it as-is for fingerprinting.
1301
+ const args = output.args;
1302
+ const fp = fingerprint(tool, args, ctx.worktree);
1303
+
1304
+ // All state mutations go through the per-session mutex (§4.3).
1305
+ await ctx.stateStore.withLock(sessionID, async () => {
1306
+ const state = await ctx.stateStore.load(sessionID);
1307
+ // Lazy fallback for subagent-only sessions: if the state file
1308
+ // doesn't exist yet (no chat.message has fired), create the
1309
+ // empty state now (spec §4.5.1).
1310
+ if (state.startedAt === 0) {
1311
+ const now = Date.now();
1312
+ state.parentAgent = null;
1313
+ state.startedAt = now;
1314
+ state.lastActivityAt = now;
1315
+ }
1316
+
1317
+ // Append the current call to the state before deciding (§5.1:
1318
+ // the count includes the current call).
1319
+ const now = Date.now();
1320
+ state.toolCalls.push({ tool, fingerprint: fp, at: now });
1321
+ // Cap toolCalls at last 50 (§4.1).
1322
+ if (state.toolCalls.length > 50) {
1323
+ state.toolCalls.splice(0, state.toolCalls.length - 50);
1324
+ }
1325
+ state.lastActivityAt = now;
1326
+ state.turnCount += 1;
1327
+
1328
+ const decision = decide(state, fp, now, ctx.options);
1329
+
1330
+ if (decision.action === "allow") {
1331
+ await ctx.stateStore.save(state);
1332
+ return;
1333
+ }
1334
+
1335
+ if (decision.action === "block") {
1336
+ state.blocksTriggered += 1;
1337
+ await ctx.stateStore.save(state);
1338
+ // Throw from the hook — surfaces as a tool error in the TUI
1339
+ // and runs BEFORE opencode's doom_loop recovery (§3.3).
1340
+ throw new Error(decision.reason);
1341
+ }
1342
+
1343
+ // warn or escalate — log first, then queue the injection.
1344
+ if (decision.action === "warn") {
1345
+ state.warningsIssued += 1;
1346
+ if (isLogOnlyWarn(decision, ctx.options)) {
1347
+ // Threshold-3 band: log only, no injection (§5.4 row 1).
1348
+ ctx.logger.warn(`bizar: ${decision.reason}`);
1349
+ } else {
1350
+ // Threshold-5/8 band: queue system-transform injection.
1351
+ ctx.pendingInjections.set(sessionID, decision.reason);
1352
+ ctx.logger.warn(`bizar: ${decision.reason}`);
1353
+ }
1354
+ } else {
1355
+ // escalate — always inject.
1356
+ ctx.pendingInjections.set(sessionID, decision.reason);
1357
+ ctx.logger.warn(`bizar: ${decision.reason}`);
1358
+ }
1359
+
1360
+ await ctx.stateStore.save(state);
1361
+ });
1362
+ },
1363
+
1364
+ // §3.1 — record the call result and update the outcome.
1365
+ "tool.execute.after": async (input, output) => {
1366
+ if (ctx.envFlags.disableLoop && ctx.envFlags.disableLog) return;
1367
+ const sessionID = input.sessionID;
1368
+ const tool = input.tool;
1369
+ if (!sessionID || !tool) return;
1370
+
1371
+ const startMs = Date.now();
1372
+
1373
+ await ctx.stateStore.withLock(sessionID, async () => {
1374
+ const state = await ctx.stateStore.load(sessionID);
1375
+ if (state.startedAt === 0) return; // nothing to update
1376
+ // Find the matching call by fingerprint. The hook appends the
1377
+ // call in `before`; we update its outcome here.
1378
+ const fp = fingerprint(tool, input.args, ctx.worktree);
1379
+ const idx = findLastIndex(state.toolCalls, (c) => c.fingerprint === fp);
1380
+ if (idx >= 0) {
1381
+ const call = state.toolCalls[idx];
1382
+ if (call) {
1383
+ call.outcome = output && typeof output.output === "string" ? "ok" : "error";
1384
+ }
1385
+ }
1386
+ state.lastActivityAt = Date.now();
1387
+ await ctx.stateStore.save(state);
1388
+ });
1389
+
1390
+ // Per-call log line (§10.1). Metadata only — no args.
1391
+ if (!ctx.envFlags.disableLog) {
1392
+ const durationMs = Date.now() - startMs;
1393
+ const outcome: "ok" | "error" = output && typeof output.output === "string" ? "ok" : "error";
1394
+ const fp = fingerprint(tool, input.args, ctx.worktree);
1395
+ try {
1396
+ await ctx.logWriter.write({
1397
+ sessionId: sessionID,
1398
+ agent: null, // per-call agent attribution removed (§4.4)
1399
+ tool,
1400
+ fingerprint: fp,
1401
+ outcome,
1402
+ durationMs,
1403
+ });
1404
+ } catch (err) {
1405
+ ctx.logger.warn(
1406
+ `bizar: log write failed: ${err instanceof Error ? err.message : String(err)}`,
1407
+ );
1408
+ }
1409
+ }
1410
+ },
1411
+
1412
+ // v0.4.2 — register the 4 background tools.
1413
+ tool: tools,
1414
+
1415
+ // v0.4.2 — dispose hook. Opencode calls this when the plugin is
1416
+ // being torn down. We do a best-effort cleanup similar to the
1417
+ // signal trap, but we do NOT call `process.exit` — that's the
1418
+ // signal handler's job.
1419
+ dispose: async () => {
1420
+ ctx.logger.debug("bizar: dispose hook fired");
1421
+ if (instanceManagerHandle !== null) {
1422
+ try {
1423
+ await instanceManagerHandle.shutdownAll();
1424
+ } catch (err: unknown) {
1425
+ ctx.logger.warn(
1426
+ `bizar: dispose: shutdownAll failed: ${
1427
+ err instanceof Error ? err.message : String(err)
1428
+ }`,
1429
+ );
1430
+ }
1431
+ }
1432
+ if (streamHandle !== null) {
1433
+ try {
1434
+ await streamHandle.disconnect();
1435
+ } catch {
1436
+ // ignore
1437
+ }
1438
+ }
1439
+ if (serveHandle !== null) {
1440
+ try {
1441
+ await serveHandle.stop();
1442
+ } catch (err: unknown) {
1443
+ ctx.logger.warn(
1444
+ `bizar: dispose: serve.stop failed: ${
1445
+ err instanceof Error ? err.message : String(err)
1446
+ }`,
1447
+ );
1448
+ }
1449
+ }
1450
+ clearServeInfo(ctx.options.stateDir, ctx.logger);
1451
+ },
1452
+ };
1453
+ }
1454
+
1455
+ function findLastIndex<T>(
1456
+ arr: readonly T[],
1457
+ predicate: (item: T) => boolean,
1458
+ ): number {
1459
+ for (let i = arr.length - 1; i >= 0; i--) {
1460
+ const item = arr[i];
1461
+ if (item !== undefined && predicate(item)) return i;
1462
+ }
1463
+ return -1;
1464
+ }
1465
+
1466
+ /**
1467
+ * When the serve child failed to start, register the 4 background tools
1468
+ * as stubs that return a clear error. This keeps the agent experience
1469
+ * consistent: calling `bizar_spawn_background` always returns JSON, not
1470
+ * a thrown exception from the tool framework. The plan tools
1471
+ * (`bizar_get_plan_comments`, `bizar_plan_action`, `bizar_wait_for_feedback`)
1472
+ * are NOT background tools — they read/write plan files directly — and
1473
+ * are always registered in `buildHooks`.
1474
+ */
1475
+ function bgDisabledTools(logger: Logger): Hooks["tool"] {
1476
+ const disabled = (name: string) =>
1477
+ async () => {
1478
+ logger.debug(`bizar: ${name} called but background agents are disabled`);
1479
+ return {
1480
+ output: JSON.stringify({
1481
+ error: "background agents are disabled (opencode serve unavailable). See plugin logs.",
1482
+ }),
1483
+ };
1484
+ };
1485
+ return {
1486
+ bizar_spawn_background: { execute: disabled("bizar_spawn_background") } as never,
1487
+ bizar_status: { execute: disabled("bizar_status") } as never,
1488
+ bizar_collect: { execute: disabled("bizar_collect") } as never,
1489
+ bizar_kill: { execute: disabled("bizar_kill") } as never,
1490
+ };
1491
+ }