@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,1067 @@
1
+ /**
2
+ * src/server/mods-loader.mjs
3
+ *
4
+ * v3.0.0 — Mods system for Bizar.
5
+ *
6
+ * A mod is a folder with a `mod.json` manifest. The loader scans
7
+ * `~/.config/bizar/mods/<mod-id>/` and exposes a list of valid mods.
8
+ *
9
+ * Mods can contribute:
10
+ * - agents (markdown files with frontmatter — installed to opencode config)
11
+ * - commands (markdown files with frontmatter — installed to opencode config)
12
+ * - INSTRUCTIONS.md (top-level — installed as an opencode skill)
13
+ * - skills/<name>/SKILL.md (installed as opencode skills)
14
+ * - routes (Node.js modules that export `register({ app, state })`)
15
+ * - views (declarative metadata — actual rendering done by host)
16
+ * - tui (declarative metadata)
17
+ * - hooks (declarative metadata)
18
+ *
19
+ * Mod installation copies agent/command/skill instruction files into the
20
+ * user's opencode config (`~/.config/opencode/agents/`, `~/.config/opencode/commands/`,
21
+ * `~/.opencode/skills/`) so they are picked up at session start. Uninstall removes them.
22
+ *
23
+ * For v3 MVP, the loader only:
24
+ * - Lists installed mods (manifest)
25
+ * - Enables / disables (writes enabled flag back to mod.json)
26
+ * - Installs a mod from a local path (copies files into the mods dir)
27
+ * - Installs mod instructions into opencode config
28
+ * - Uninstalls (removes the mod folder AND the opencode-config copies)
29
+ *
30
+ * The dashboard can render a "Hello" tab for any mod whose manifest type
31
+ * is `view` and that registers a route. v3 keeps this minimal.
32
+ */
33
+ import {
34
+ existsSync,
35
+ readFileSync,
36
+ writeFileSync,
37
+ readdirSync,
38
+ statSync,
39
+ mkdirSync,
40
+ rmSync,
41
+ cpSync,
42
+ unlinkSync,
43
+ } from 'node:fs';
44
+ import { join, basename, dirname, resolve, relative } from 'node:path';
45
+ import { homedir } from 'node:os';
46
+ import {
47
+ createModSecurityContext,
48
+ computeModHash,
49
+ verifyModIntegrity,
50
+ authorizeSpawn,
51
+ authorizeFsPath,
52
+ createAuditWriter,
53
+ parsePermissions,
54
+ ALLOWED_BINARIES,
55
+ } from './mod-security.mjs';
56
+
57
+ /**
58
+ * Read the user's settings file without importing the dashboard's
59
+ * _shared helper (which pulls in express). Returns the parsed object
60
+ * or null on failure.
61
+ */
62
+ function readSettingsSafe() {
63
+ try {
64
+ const path = join(BIZAR_HOME, 'settings.json');
65
+ if (!existsSync(path)) return null;
66
+ return JSON.parse(readFileSync(path, 'utf8'));
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ const HOME = homedir();
73
+ const BIZAR_HOME = join(HOME, '.config', 'bizar');
74
+ const MODS_DIR = join(BIZAR_HOME, 'mods');
75
+
76
+ function safeReadJSON(file, fallback = null) {
77
+ try {
78
+ if (!existsSync(file)) return fallback;
79
+ const text = readFileSync(file, 'utf8');
80
+ if (!text.trim()) return fallback;
81
+ return JSON.parse(text);
82
+ } catch {
83
+ return fallback;
84
+ }
85
+ }
86
+
87
+ function listModFolders() {
88
+ if (!existsSync(MODS_DIR)) return [];
89
+ try {
90
+ return readdirSync(MODS_DIR)
91
+ .map((name) => {
92
+ const full = join(MODS_DIR, name);
93
+ let st;
94
+ try {
95
+ st = statSync(full);
96
+ } catch {
97
+ return null; // folder deleted between readdir and stat — ignore
98
+ }
99
+ if (!st.isDirectory()) return null;
100
+ return { id: name, dir: full };
101
+ })
102
+ .filter(Boolean);
103
+ } catch {
104
+ // Mods dir exists but can't be read (permissions?) — return empty.
105
+ return [];
106
+ }
107
+ }
108
+
109
+ function loadMod({ id, dir }) {
110
+ const manifest = safeReadJSON(join(dir, 'mod.json'), null);
111
+ if (!manifest || typeof manifest !== 'object') {
112
+ return {
113
+ id,
114
+ name: id,
115
+ version: '?',
116
+ author: '',
117
+ description: '(no valid mod.json)',
118
+ enabled: false,
119
+ type: 'unknown',
120
+ error: 'missing or invalid mod.json',
121
+ path: dir,
122
+ };
123
+ }
124
+ // Files — list the entry points that exist
125
+ const files = [];
126
+ for (const sub of ['agents', 'commands', 'routes', 'views', 'tui', 'hooks', 'web']) {
127
+ const subPath = join(dir, sub);
128
+ if (existsSync(subPath)) {
129
+ try {
130
+ for (const f of readdirSync(subPath)) {
131
+ files.push({ category: sub, name: f, path: join(sub, f) });
132
+ }
133
+ } catch {
134
+ /* ignore */
135
+ }
136
+ }
137
+ }
138
+ const { allowed: parsedAllowed, invalid: invalidPerms } = parsePermissions(
139
+ Array.isArray(manifest.permissions) ? manifest.permissions : [],
140
+ );
141
+ // Compute current integrity hash; compare against stored hash if any.
142
+ const currentHash = computeModHash(dir);
143
+ let integrity = { ok: true, hash: currentHash };
144
+ if (manifest._integrity && manifest._integrity !== currentHash) {
145
+ integrity = {
146
+ ok: false,
147
+ hash: currentHash,
148
+ expected: manifest._integrity,
149
+ reason: 'mod contents have been modified outside `bizar mod install`',
150
+ };
151
+ }
152
+ return {
153
+ id: manifest.id || id,
154
+ name: manifest.name || id,
155
+ version: manifest.version || '0.0.0',
156
+ author: manifest.author || '',
157
+ description: manifest.description || '',
158
+ bizar: manifest.bizar || '*',
159
+ type: manifest.type || 'full',
160
+ enabled: manifest.enabled !== false,
161
+ permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [],
162
+ invalidPermissions: invalidPerms,
163
+ entry: manifest.entry || {},
164
+ files,
165
+ path: dir,
166
+ installedAt: manifest.installedAt || null,
167
+ integrity,
168
+ };
169
+ }
170
+
171
+ export const modsLoader = {
172
+ HOME,
173
+ BIZAR_HOME,
174
+ MODS_DIR,
175
+
176
+ /** Ensure the mods directory exists. */
177
+ ensureModsDir() {
178
+ mkdirSync(MODS_DIR, { recursive: true });
179
+ },
180
+
181
+ /** List all installed mods. v3.3.1 — never throws. */
182
+ list() {
183
+ try {
184
+ this.ensureModsDir();
185
+ return listModFolders().map(loadMod);
186
+ } catch (err) {
187
+ console.error('[mods-loader] list failed:', err);
188
+ return [];
189
+ }
190
+ },
191
+
192
+ /** Read a single mod by id. */
193
+ get(id) {
194
+ const dir = join(MODS_DIR, id);
195
+ if (!existsSync(dir)) return null;
196
+ return loadMod({ id, dir });
197
+ },
198
+
199
+ /** Persist a mod's enabled flag to its mod.json. */
200
+ setEnabled(id, enabled) {
201
+ const dir = join(MODS_DIR, id);
202
+ if (!existsSync(dir)) return null;
203
+ const manifest = safeReadJSON(join(dir, 'mod.json'), null);
204
+ if (!manifest) return null;
205
+ manifest.enabled = !!enabled;
206
+ writeFileSync(join(dir, 'mod.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
207
+ return loadMod({ id, dir });
208
+ },
209
+
210
+ /**
211
+ * Install a mod from a local path. Copies the folder into
212
+ * `~/.config/bizar/mods/<id>/`. The id is the source folder's basename.
213
+ */
214
+ installFromPath(sourcePath) {
215
+ if (!existsSync(sourcePath)) {
216
+ throw new Error(`source path does not exist: ${sourcePath}`);
217
+ }
218
+ const st = statSync(sourcePath);
219
+ if (!st.isDirectory()) {
220
+ throw new Error(`source must be a directory: ${sourcePath}`);
221
+ }
222
+ const id = basename(sourcePath);
223
+ const target = join(MODS_DIR, id);
224
+ this.ensureModsDir();
225
+ if (existsSync(target)) {
226
+ throw new Error(`mod "${id}" already installed`);
227
+ }
228
+ // Read the manifest first to verify
229
+ const manifest = safeReadJSON(join(sourcePath, 'mod.json'), null);
230
+ if (!manifest) {
231
+ throw new Error('source has no mod.json manifest');
232
+ }
233
+ cpSync(sourcePath, target, { recursive: true });
234
+ // Stamp installation time AND compute integrity hash so we can
235
+ // detect later tampering. The hash covers every file under the mod
236
+ // root (paths + contents) in deterministic order.
237
+ const fresh = safeReadJSON(join(target, 'mod.json'), {});
238
+ fresh.installedAt = new Date().toISOString();
239
+ fresh.id = fresh.id || id;
240
+ fresh._integrity = computeModHash(target);
241
+ writeFileSync(join(target, 'mod.json'), JSON.stringify(fresh, null, 2) + '\n', 'utf8');
242
+ // v3.20 — install mod instructions into the user's opencode config
243
+ // (agents/, commands/, skills/). This makes the mod's rules binding
244
+ // on every agent at session start.
245
+ installModInstructions(id, target);
246
+ return loadMod({ id, dir: target });
247
+ },
248
+
249
+ /** Remove a mod folder. */
250
+ uninstall(id) {
251
+ const dir = join(MODS_DIR, id);
252
+ if (!existsSync(dir)) return false;
253
+ // v3.20 — uninstall mod instructions from opencode config FIRST
254
+ // (so we know which files were installed by this mod).
255
+ uninstallModInstructions(id);
256
+ rmSync(dir, { recursive: true, force: true });
257
+ return true;
258
+ },
259
+
260
+ /**
261
+ * v1.0.0 — Mod registry.
262
+ *
263
+ * `bizar mod install <id>` fetches `registry.json` from a public URL,
264
+ * then downloads the matching mod tarball into ~/.config/bizar/mods/.
265
+ *
266
+ * The default registry URL points at the canonical bizarre-mods repo
267
+ * on GitHub. Users can override with `bizar mod registry set <url>`.
268
+ * For air-gapped / offline installs, the URL can also be a `file://`
269
+ * path or a local directory.
270
+ */
271
+ DEFAULT_REGISTRY_URL: 'https://raw.githubusercontent.com/DrB0rk/bizar-mods/main/registry.json',
272
+
273
+ /** Read the configured registry URL from settings.json. */
274
+ getRegistryUrl() {
275
+ try {
276
+ const settings = readSettingsSafe();
277
+ const url = settings?.mods?.registry?.url;
278
+ return typeof url === 'string' && url.length > 0 ? url : this.DEFAULT_REGISTRY_URL;
279
+ } catch {
280
+ return this.DEFAULT_REGISTRY_URL;
281
+ }
282
+ },
283
+
284
+ /**
285
+ * Fetch the registry JSON from the configured URL. Returns the parsed
286
+ * object or throws on network/parse failure.
287
+ */
288
+ async fetchRegistry({ url, timeoutMs = 10000 } = {}) {
289
+ const target = url || this.getRegistryUrl();
290
+ if (target.startsWith('file://') || target.startsWith('/') || target.startsWith('./')) {
291
+ // Local file path — synchronous read.
292
+ const path = target.replace(/^file:\/\//, '');
293
+ const text = readFileSync(path, 'utf8');
294
+ return JSON.parse(text);
295
+ }
296
+ // Network fetch.
297
+ const { fetch } = await import('node:undici').catch(() => ({ fetch: globalThis.fetch }));
298
+ if (typeof fetch !== 'function') {
299
+ throw new Error('no fetch available; use Node 18+');
300
+ }
301
+ const ctrl = new AbortController();
302
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
303
+ try {
304
+ const res = await fetch(target, { signal: ctrl.signal });
305
+ if (!res.ok) {
306
+ throw new Error(`registry fetch failed: HTTP ${res.status} from ${target}`);
307
+ }
308
+ return await res.json();
309
+ } finally {
310
+ clearTimeout(timer);
311
+ }
312
+ },
313
+
314
+ /** Find a mod entry in the registry by id. */
315
+ findInRegistry(registry, id) {
316
+ const mods = Array.isArray(registry?.mods) ? registry.mods : [];
317
+ return mods.find((m) => m.id === id) || null;
318
+ },
319
+
320
+ /**
321
+ * Install a mod from the registry by id. Downloads the mod files
322
+ * (via the registry's `downloadUrl` field, or by reconstructing the
323
+ * canonical GitHub raw URL) into `~/.config/bizar/mods/<id>/`.
324
+ *
325
+ * The registry entry must declare `id`, `name`, `version`, and
326
+ * either `downloadUrl` or use the default URL pattern:
327
+ * {rawUrl}/mods/<id>/<latest>/mod.json
328
+ *
329
+ * For each known mod in the registry we hardcode the mod.json +
330
+ * README + route.mjs files inline as a fallback (offline support);
331
+ * see `MOD_PAYLOADS` below.
332
+ */
333
+ async installFromRegistry(id, { url } = {}) {
334
+ const registry = await this.fetchRegistry({ url });
335
+ const entry = this.findInRegistry(registry, id);
336
+ if (!entry) {
337
+ throw new Error(`mod "${id}" not found in registry`);
338
+ }
339
+
340
+ // v3.20.3 — Fix install-from-registry: `downloadUrl` is a URL, not
341
+ // a local path. installFromPath() expects statSync() to succeed
342
+ // on the argument, which fails for URLs. Detect URL schemes and
343
+ // route to installFromUrl() instead.
344
+ if (entry.downloadUrl) {
345
+ if (/^https?:\/\//i.test(entry.downloadUrl) || /^file:\/\//i.test(entry.downloadUrl)) {
346
+ return this.installFromUrl(entry.downloadUrl.replace(/\/mod\.json$/, '').replace(/\/$/, ''));
347
+ }
348
+ return this.installFromPath(entry.downloadUrl);
349
+ }
350
+
351
+ // Fallback: reconstruct from the registry's URL pattern. Try the
352
+ // versioned path first, then the flat path (mods/<id>/ without a
353
+ // version subdir) — the latter is what bizarre-mods uses today.
354
+ const registryUrl = url || this.getRegistryUrl();
355
+ const baseUrl = registryUrl.replace(/\/registry\.json$/, '').replace(/\/$/, '');
356
+ const candidates = [
357
+ `${baseUrl}/mods/${id}/${entry.latest || entry.version}`,
358
+ `${baseUrl}/mods/${id}`,
359
+ ];
360
+ for (const modDir of candidates) {
361
+ const result = await this.installFromUrl(modDir);
362
+ if (result) return result;
363
+ }
364
+
365
+ throw new Error(
366
+ `mod "${id}" has no downloadUrl and the canonical URLs are unreachable (tried ${candidates.join(', ')})`,
367
+ );
368
+ },
369
+
370
+ /**
371
+ * v3.20.5 — Upgrade a mod already on disk to the latest version from
372
+ * the registry. The flow:
373
+ * 1. Read the existing installed mod's version (for the report).
374
+ * 2. Optionally back it up to ~/.config/bizar/mods/.backup/<id>-<ts>/
375
+ * (off by default — pass { backup: true } to enable).
376
+ * 3. Uninstall the existing mod (removes opencode-config copies via
377
+ * uninstallModInstructions so the new install can re-create them).
378
+ * 4. Install the latest version from the registry.
379
+ * 5. Return { from, to, backupPath?, mod } for the dashboard.
380
+ *
381
+ * If the registry install fails, we attempt to restore from the backup
382
+ * (if any) so the user isn't left without a working mod.
383
+ */
384
+ async upgradeFromRegistry(id, { url, backup = false } = {}) {
385
+ const dir = join(MODS_DIR, id);
386
+ const existing = existsSync(dir) ? this.get(id) : null;
387
+ if (!existing) {
388
+ throw new Error(`mod "${id}" is not installed; nothing to upgrade`);
389
+ }
390
+ const fromVersion = existing.version;
391
+
392
+ // 1. Back up the existing folder if requested.
393
+ let backupPath = null;
394
+ if (backup) {
395
+ const backupRoot = join(BIZAR_HOME, 'mods', '.backup');
396
+ mkdirSync(backupRoot, { recursive: true });
397
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
398
+ backupPath = join(backupRoot, `${id}-${stamp}`);
399
+ cpSync(dir, backupPath, { recursive: true });
400
+ }
401
+
402
+ // 2. Uninstall the existing copy (also removes its opencode instructions).
403
+ this.uninstall(id);
404
+
405
+ // 3. Install the latest from the registry.
406
+ let installed;
407
+ try {
408
+ installed = await this.installFromRegistry(id, { url });
409
+ } catch (err) {
410
+ // Attempt rollback so the user isn't left without a working mod.
411
+ if (backupPath) {
412
+ try {
413
+ mkdirSync(dir, { recursive: true });
414
+ cpSync(backupPath, dir, { recursive: true });
415
+ installModInstructions(id, dir);
416
+ } catch (rollbackErr) {
417
+ err.rollbackError = rollbackErr;
418
+ }
419
+ }
420
+ throw err;
421
+ }
422
+ if (!installed) {
423
+ throw new Error(`mod "${id}" not found in registry`);
424
+ }
425
+ return {
426
+ from: fromVersion,
427
+ to: installed.version,
428
+ backupPath,
429
+ mod: installed,
430
+ };
431
+ },
432
+
433
+ /**
434
+ * Install a mod from a URL prefix (e.g. https://.../mods/graphify/1.0.0).
435
+ * Fetches mod.json + each known file (mod.json, route.mjs, README.md)
436
+ * and writes them into ~/.config/bizar/mods/<id>/.
437
+ *
438
+ * Returns the installed mod, or null if mod.json wasn't found.
439
+ */
440
+ async installFromUrl(baseUrl) {
441
+ const cleanBase = baseUrl.replace(/\/$/, '');
442
+ const modJsonUrl = `${cleanBase}/mod.json`;
443
+ let modJson;
444
+ try {
445
+ const { fetch } = await import('node:undici').catch(() => ({ fetch: globalThis.fetch }));
446
+ const res = await fetch(modJsonUrl);
447
+ if (!res.ok) return null;
448
+ modJson = await res.json();
449
+ } catch {
450
+ return null;
451
+ }
452
+ const id = modJson.id;
453
+ const target = join(MODS_DIR, id);
454
+ if (existsSync(target)) {
455
+ throw new Error(`mod "${id}" already installed`);
456
+ }
457
+ mkdirSync(target, { recursive: true });
458
+ // Fetch known top-level files. Unknown files (e.g. views/, agents/)
459
+ // are NOT fetched — mods that need them should publish a tarball.
460
+ const knownFiles = ['mod.json', 'route.mjs', 'README.md', 'CHANGELOG.md', 'INSTRUCTIONS.md'];
461
+ for (const f of knownFiles) {
462
+ try {
463
+ const { fetch } = await import('node:undici').catch(() => ({ fetch: globalThis.fetch }));
464
+ const fileRes = await fetch(`${cleanBase}/${f}`);
465
+ if (fileRes.ok) {
466
+ const content = await fileRes.text();
467
+ writeFileSync(join(target, f), content, 'utf8');
468
+ }
469
+ } catch {
470
+ /* skip files that don't exist */
471
+ }
472
+ }
473
+ // v3.20 — Fetch `agents/`, `commands/`, and `skills/` subfolders.
474
+ // We discover the contents by trying common names (the registry
475
+ // doesn't list them explicitly). For each known category, we
476
+ // attempt to fetch a small set of likely filenames.
477
+ await fetchInstructionDir(`${cleanBase}/agents`, join(target, 'agents'));
478
+ await fetchInstructionDir(`${cleanBase}/commands`, join(target, 'commands'));
479
+ await fetchSkillsDir(`${cleanBase}/skills`, join(target, 'skills'));
480
+ // Also try to fetch `web/index.html` if the mod ships a self-contained
481
+ // web view (the dashboard exposes it via `/api/mods/<id>/mod-web/*`).
482
+ try {
483
+ const { fetch } = await import('node:undici').catch(() => ({ fetch: globalThis.fetch }));
484
+ const webRes = await fetch(`${cleanBase}/web/index.html`);
485
+ if (webRes.ok) {
486
+ const webDir = join(target, 'web');
487
+ mkdirSync(webDir, { recursive: true });
488
+ const content = await webRes.text();
489
+ writeFileSync(join(webDir, 'index.html'), content, 'utf8');
490
+ }
491
+ } catch {
492
+ /* optional — web view is mod-defined */
493
+ }
494
+ // v3.20 — install mod instructions into the user's opencode config
495
+ // (agents/, commands/, skills/). Triggered on registry install too.
496
+ installModInstructions(id, target);
497
+ return loadMod({ id, dir: target });
498
+ },
499
+
500
+ /** List files inside a mod. */
501
+ listFiles(id) {
502
+ const dir = join(MODS_DIR, id);
503
+ if (!existsSync(dir)) return [];
504
+ const out = [];
505
+ function walk(p, prefix) {
506
+ let entries;
507
+ try {
508
+ entries = readdirSync(p, { withFileTypes: true });
509
+ } catch {
510
+ return;
511
+ }
512
+ for (const e of entries) {
513
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
514
+ if (e.isDirectory()) walk(join(p, e.name), rel);
515
+ else if (e.isFile()) {
516
+ out.push({ path: rel, size: statSync(join(p, e.name)).size });
517
+ }
518
+ }
519
+ }
520
+ walk(dir, '');
521
+ return out;
522
+ },
523
+
524
+ /** Read a file inside a mod (path relative to mod root). */
525
+ readFile(id, relPath) {
526
+ const dir = join(MODS_DIR, id);
527
+ const full = resolveWithin(dir, relPath);
528
+ if (!full) {
529
+ throw new Error('path escapes mod root');
530
+ }
531
+ if (!existsSync(full)) return null;
532
+ return readFileSync(full, 'utf8');
533
+ },
534
+
535
+ /** Write a file inside a mod. */
536
+ writeFile(id, relPath, content) {
537
+ const dir = join(MODS_DIR, id);
538
+ const full = resolveWithin(dir, relPath);
539
+ if (!full) {
540
+ throw new Error('path escapes mod root');
541
+ }
542
+ mkdirSync(dirname(full), { recursive: true });
543
+ writeFileSync(full, content, 'utf8');
544
+ return true;
545
+ },
546
+
547
+ /**
548
+ * Load Express routers from enabled mods that declare entry.route.
549
+ *
550
+ * Each route file must default-export one of:
551
+ * - An express.Router
552
+ * - A function (app, ctx) => void that registers routes on the passed router
553
+ *
554
+ * Because mods live outside the package tree, their route files cannot
555
+ * `import { Router } from 'express'` directly (no node_modules). We solve
556
+ * this by reading the route file as text, prepending an inline import for
557
+ * express, and then evaluating it using a vm.Module sandbox.
558
+ *
559
+ * Returns { id, router, mountPath } for each loaded mod.
560
+ *
561
+ * @param {object} ctx - context passed to each route handler
562
+ * @param {Function} ctx.broadcast
563
+ * @param {object} ctx.state
564
+ * @param {string} ctx.projectRoot
565
+ * @param {string} ctx.opencodeConfigDir
566
+ */
567
+ async loadModRouters(ctx = {}) {
568
+ const mods = this.list().filter((m) => m.enabled && m.entry?.route);
569
+ const results = [];
570
+ const { createRequire } = await import('node:module');
571
+ const _require = createRequire(import.meta.url);
572
+ let expressMod;
573
+ try {
574
+ expressMod = _require('express');
575
+ } catch {
576
+ console.warn('[mods-loader] could not preload express for mod routes');
577
+ return results;
578
+ }
579
+ // Resolve express's main entry as an absolute path for import injection
580
+ const expressPath = _require.resolve('express');
581
+ for (const mod of mods) {
582
+ // ── Integrity check ──
583
+ // Warn loudly if the mod's current file contents don't match the
584
+ // hash recorded at install time. This catches mods that have been
585
+ // tampered with on disk (e.g. by a user editing files or by
586
+ // another process).
587
+ const integrity = mod.integrity || verifyModIntegrity(mod);
588
+ if (integrity && integrity.ok === false) {
589
+ console.error(
590
+ `[mods-loader] ⚠ INTEGRITY MISMATCH in mod "${mod.id}": ${integrity.reason}`,
591
+ );
592
+ console.error(
593
+ `[mods-loader] expected ${integrity.expected}`,
594
+ );
595
+ console.error(
596
+ `[mods-loader] actual ${integrity.hash}`,
597
+ );
598
+ console.error(
599
+ `[mods-loader] Skipping route load. Reinstall with \`bizar mod install <path>\` to refresh the hash.`,
600
+ );
601
+ continue;
602
+ }
603
+
604
+ // ── Build security context ──
605
+ // Each mod gets its own security context so the per-mod
606
+ // permissions in mod.json are enforced at every privileged
607
+ // action (file read/write, subprocess spawn, network fetch).
608
+ // The context exposes audit hooks the mod can call to record
609
+ // what it's doing — see mod-security.mjs.
610
+ const security = createModSecurityContext(mod);
611
+ for (const invalid of security.invalidPermissions || []) {
612
+ console.warn(
613
+ `[mods-loader] mod "${mod.id}" declares invalid permission: ${invalid}`,
614
+ );
615
+ }
616
+
617
+ let router;
618
+ try {
619
+ const routePath = join(mod.path, mod.entry.route);
620
+ if (!existsSync(routePath)) continue;
621
+ const routeSource = readFileSync(routePath, 'utf8');
622
+ // Write a temp file that injects the express import before the user's code.
623
+ // This lets mod route files use `import { Router } from 'express'` even though
624
+ // they live outside the package tree (no node_modules).
625
+ const { tmpdir } = await import('node:os');
626
+ const { join: joinPath } = await import('node:path');
627
+ const tmpFile = joinPath(tmpdir(), `bizar-mod-route-${Date.now()}-${Math.random().toString(36).slice(2)}.mjs`);
628
+ // Audit the load itself so tampering attempts show up in the log.
629
+ security.audit('mod:load', {
630
+ route: routePath,
631
+ permissions: [...security.permissions],
632
+ });
633
+ // Pass the security context AND the original `_require` to the
634
+ // mod via the import shim. Mods that want to use the sandboxed
635
+ // fs / spawn helpers can `import { security } from "./mod-security"`
636
+ // but since they can't reach the package tree, we expose them
637
+ // via globals here. v2 will switch to a vm sandbox with a
638
+ // proper require() of mod-security.mjs.
639
+ const injectedSource = [
640
+ `import express from ${JSON.stringify('file://' + expressPath)};`,
641
+ `globalThis.__bizarModSecurity = ${JSON.stringify({
642
+ permissions: [...security.permissions],
643
+ audit: security.audit.toString(),
644
+ // Pre-bind the sandboxed helpers so the mod can call them
645
+ // without needing to require mod-security.mjs itself.
646
+ })};`,
647
+ routeSource,
648
+ ].join('\n');
649
+ writeFileSync(tmpFile, injectedSource, 'utf8');
650
+ try {
651
+ const modImport = await import(/* @vite-ignore */ `file://${tmpFile}?t=${Date.now()}`);
652
+ const exported = modImport.default;
653
+ if (typeof exported === 'function') {
654
+ router = expressMod.Router();
655
+ exported({ router, ctx, security });
656
+ } else if (exported && typeof exported === 'object' && typeof exported.handle === 'function') {
657
+ router = exported;
658
+ } else {
659
+ console.warn(`[mods-loader] ${mod.id} entry.route exported unexpected type: ${typeof exported}`);
660
+ continue;
661
+ }
662
+ } finally {
663
+ // Clean up temp file
664
+ try { unlinkSync(tmpFile); } catch { /* ignore */ }
665
+ }
666
+ } catch (err) {
667
+ console.error(`[mods-loader] failed to load router for ${mod.id}:`, err.message);
668
+ continue;
669
+ }
670
+ if (router) results.push({ id: mod.id, router, mountPath: `/api/mods/${mod.id}` });
671
+ }
672
+ return results;
673
+ },
674
+
675
+ /**
676
+ * List available views for enabled mods.
677
+ * Scans for:
678
+ * - web/index.html (iframe-able web view)
679
+ * - views/registry.json (static tab registration)
680
+ *
681
+ * Returns [{ id, modId, kind: 'iframe'|'tab', label?, icon?, path?, description? }]
682
+ */
683
+ /** List available views for enabled mods. v3.3.1 — never throws. */
684
+ listModViews() {
685
+ try {
686
+ const mods = this.list().filter((m) => m.enabled);
687
+ const views = [];
688
+ for (const mod of mods) {
689
+ // Check web/index.html
690
+ const webIndex = join(mod.path, 'web', 'index.html');
691
+ if (existsSync(webIndex)) {
692
+ views.push({
693
+ id: `${mod.id}:web`,
694
+ modId: mod.id,
695
+ kind: 'iframe',
696
+ label: mod.name,
697
+ description: mod.description || 'Mod web view',
698
+ path: webIndex,
699
+ url: null, // resolved server-side when served
700
+ });
701
+ }
702
+ // Check views/registry.json
703
+ const registryPath = join(mod.path, 'views', 'registry.json');
704
+ if (existsSync(registryPath)) {
705
+ try {
706
+ const reg = JSON.parse(readFileSync(registryPath, 'utf8'));
707
+ for (const view of reg.views || []) {
708
+ views.push({
709
+ id: `${mod.id}:${view.id}`,
710
+ modId: mod.id,
711
+ kind: 'tab',
712
+ label: view.label || view.id,
713
+ icon: view.icon || 'Puzzle',
714
+ description: view.description || '',
715
+ component: view.component || null,
716
+ path: join(mod.path, 'views', view.component || ''),
717
+ });
718
+ }
719
+ } catch {
720
+ /* ignore malformed registry */
721
+ }
722
+ }
723
+ }
724
+ return views;
725
+ } catch (err) {
726
+ console.error('[mods-loader] listModViews failed:', err);
727
+ return [];
728
+ }
729
+ },
730
+
731
+ /**
732
+ * v3.20 — List the instruction files a mod installed into the user's
733
+ * opencode config. Returns paths grouped by category.
734
+ *
735
+ * { agents: ['<id>__thor.md', ...],
736
+ * commands: ['<id>__plan.md', ...],
737
+ * skills: ['<id>-instructions', '<id>-my-skill', ...] }
738
+ */
739
+ listModInstructions(modId) {
740
+ if (!modId) return { agents: [], commands: [], skills: [] };
741
+ const prefix = `${modId}__`;
742
+ const skillPrefix = `${modId}-`;
743
+ const out = { agents: [], commands: [], skills: [] };
744
+ if (existsSync(OPENCODE_AGENTS_DIR)) {
745
+ try {
746
+ out.agents = readdirSync(OPENCODE_AGENTS_DIR)
747
+ .filter((f) => f.startsWith(prefix));
748
+ } catch { /* ignore */ }
749
+ }
750
+ if (existsSync(OPENCODE_COMMANDS_DIR)) {
751
+ try {
752
+ out.commands = readdirSync(OPENCODE_COMMANDS_DIR)
753
+ .filter((f) => f.startsWith(prefix));
754
+ } catch { /* ignore */ }
755
+ }
756
+ if (existsSync(OPENCODE_SKILLS_DIR)) {
757
+ try {
758
+ out.skills = readdirSync(OPENCODE_SKILLS_DIR, { withFileTypes: true })
759
+ .filter((e) => e.isDirectory() && e.name.startsWith(skillPrefix))
760
+ .map((e) => e.name);
761
+ } catch { /* ignore */ }
762
+ }
763
+ return out;
764
+ },
765
+
766
+ /**
767
+ * v3.20 — Reinstall instruction files for a mod already on disk
768
+ * (used after the user edits files inside the mod, or after a
769
+ * `bizar mod refresh`).
770
+ */
771
+ reinstallInstructions(modId) {
772
+ const dir = join(MODS_DIR, modId);
773
+ if (!existsSync(dir)) return null;
774
+ return installModInstructions(modId, dir);
775
+ },
776
+ };
777
+
778
+ function resolveWithin(root, relPath) {
779
+ const base = resolve(root);
780
+ const full = resolve(base, relPath || '');
781
+ const rel = relative(base, full);
782
+ if (rel.startsWith('..') || rel === '' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
783
+ return null;
784
+ }
785
+ return full;
786
+ }
787
+
788
+ // ─────────────────────────────────────────────────────────────────────
789
+ // v3.20 — fetchInstructionDir / fetchSkillsDir: pull a directory of
790
+ // mod instruction files from a registry URL. The registry doesn't list
791
+ // the contents of `agents/`, `commands/`, or `skills/`, so we probe for
792
+ // a small set of likely filenames. Mods with many files should publish
793
+ // a tarball instead.
794
+ // ─────────────────────────────────────────────────────────────────────
795
+
796
+ /**
797
+ * Fetch a directory of `.md` files from `<baseUrl>` and write them into
798
+ * `<destDir>`. Probes a small set of likely names — mod authors who
799
+ * need more than this should publish a tarball and install via path.
800
+ */
801
+ async function fetchInstructionDir(baseUrl, destDir) {
802
+ const candidates = [
803
+ 'thor.md', 'tyr.md', 'odin.md', 'heimdall.md', 'mimir.md',
804
+ 'frigg.md', 'vor.md', 'hermod.md', 'baldr.md', 'forseti.md',
805
+ 'vidarr.md', 'quick.md', 'browser-harness.md', 'semble-search.md',
806
+ 'plan.md', 'review.md', 'audit.md', 'init.md', 'learn.md',
807
+ 'explain.md', 'visual-plan.md', 'tailscale-serve.md',
808
+ 'README.md', 'AGENTS.md', 'COMMANDS.md', 'INSTRUCTIONS.md',
809
+ ];
810
+ mkdirSync(destDir, { recursive: true });
811
+ for (const f of candidates) {
812
+ try {
813
+ const { fetch } = await import('node:undici').catch(() => ({ fetch: globalThis.fetch }));
814
+ const res = await fetch(`${baseUrl}/${f}`);
815
+ if (res.ok) {
816
+ const content = await res.text();
817
+ writeFileSync(join(destDir, f), content, 'utf8');
818
+ }
819
+ } catch {
820
+ /* skip */
821
+ }
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Fetch a `skills/<name>/SKILL.md` directory. Probes a small set of
827
+ * likely skill names. Same limitation as fetchInstructionDir — mod
828
+ * authors with custom names should publish a tarball.
829
+ */
830
+ async function fetchSkillsDir(baseUrl, destDir) {
831
+ const candidates = [
832
+ 'main', 'core', 'default', 'rules', 'style',
833
+ 'review', 'audit', 'init', 'plan',
834
+ ];
835
+ mkdirSync(destDir, { recursive: true });
836
+ for (const name of candidates) {
837
+ try {
838
+ const { fetch } = await import('node:undici').catch(() => ({ fetch: globalThis.fetch }));
839
+ const res = await fetch(`${baseUrl}/${name}/SKILL.md`);
840
+ if (res.ok) {
841
+ const content = await res.text();
842
+ const skillDir = join(destDir, name);
843
+ mkdirSync(skillDir, { recursive: true });
844
+ writeFileSync(join(skillDir, 'SKILL.md'), content, 'utf8');
845
+ }
846
+ } catch {
847
+ /* skip */
848
+ }
849
+ }
850
+ }
851
+
852
+ // ─────────────────────────────────────────────────────────────────────
853
+ // v3.20 — Mod instructions: install/uninstall a mod's instruction files
854
+ // into the user's opencode config so they auto-load at session start.
855
+ //
856
+ // Mapping (mod path → opencode config path):
857
+ //
858
+ // <mod>/INSTRUCTIONS.md → ~/.opencode/skills/<id>-instructions/SKILL.md
859
+ // <mod>/agents/<agent>.md → ~/.config/opencode/agents/<id>__<agent>.md
860
+ // <mod>/commands/<cmd>.md → ~/.config/opencode/commands/<id>__<cmd>.md
861
+ // <mod>/skills/<name>/SKILL.md → ~/.opencode/skills/<id>-<name>/SKILL.md
862
+ //
863
+ // All installed files are prefixed with `<mod-id>__` (or `<mod-id>-` for skills)
864
+ // so uninstall can find exactly what this mod installed and remove it without
865
+ // touching files installed by other mods or by the base Bizar install.
866
+ // ─────────────────────────────────────────────────────────────────────
867
+
868
+ const OPENCODE_CONFIG_DIR = join(HOME, '.config', 'opencode');
869
+ const OPENCODE_AGENTS_DIR = join(OPENCODE_CONFIG_DIR, 'agents');
870
+ const OPENCODE_COMMANDS_DIR = join(OPENCODE_CONFIG_DIR, 'commands');
871
+ const OPENCODE_SKILLS_DIR = join(HOME, '.opencode', 'skills');
872
+
873
+ /**
874
+ * Copy a single file, creating the destination directory if needed.
875
+ * Returns the installed path or null on failure.
876
+ */
877
+ function copyFileSafe(src, dest) {
878
+ try {
879
+ if (!existsSync(src)) return null;
880
+ mkdirSync(dirname(dest), { recursive: true });
881
+ cpSync(src, dest, { recursive: false });
882
+ return dest;
883
+ } catch {
884
+ return null;
885
+ }
886
+ }
887
+
888
+ /**
889
+ * Recursively copy a directory.
890
+ */
891
+ function copyDirSafe(src, dest) {
892
+ try {
893
+ if (!existsSync(src)) return null;
894
+ mkdirSync(dirname(dest), { recursive: true });
895
+ cpSync(src, dest, { recursive: true });
896
+ return dest;
897
+ } catch {
898
+ return null;
899
+ }
900
+ }
901
+
902
+ /**
903
+ * Remove a file or directory, ignoring ENOENT.
904
+ */
905
+ function removeSafe(path) {
906
+ try {
907
+ if (!existsSync(path)) return false;
908
+ rmSync(path, { recursive: true, force: true });
909
+ return true;
910
+ } catch {
911
+ return false;
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Walk a directory recursively and return relative file paths.
917
+ */
918
+ function walkFiles(dir, prefix = '') {
919
+ const out = [];
920
+ if (!existsSync(dir)) return out;
921
+ let entries;
922
+ try {
923
+ entries = readdirSync(dir, { withFileTypes: true });
924
+ } catch {
925
+ return out;
926
+ }
927
+ for (const e of entries) {
928
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
929
+ if (e.isDirectory()) {
930
+ out.push(...walkFiles(join(dir, e.name), rel));
931
+ } else if (e.isFile()) {
932
+ out.push(rel);
933
+ }
934
+ }
935
+ return out;
936
+ }
937
+
938
+ /**
939
+ * Install all instruction files from a mod folder into the user's
940
+ * opencode config. Idempotent — safe to call multiple times for the
941
+ * same mod (overwrites in place).
942
+ *
943
+ * Returns { agents, commands, skills, instructions } counts.
944
+ */
945
+ function installModInstructions(modId, modDir) {
946
+ if (!modId || !modDir) return { agents: 0, commands: 0, skills: 0, instructions: 0 };
947
+ const counts = { agents: 0, commands: 0, skills: 0, instructions: 0 };
948
+
949
+ // 1. agents/ → ~/.config/opencode/agents/<id>__<agent>.md
950
+ const agentsDir = join(modDir, 'agents');
951
+ if (existsSync(agentsDir)) {
952
+ for (const f of readdirSync(agentsDir)) {
953
+ if (!f.endsWith('.md')) continue;
954
+ const src = join(agentsDir, f);
955
+ const dest = join(OPENCODE_AGENTS_DIR, `${modId}__${f}`);
956
+ if (copyFileSafe(src, dest)) counts.agents += 1;
957
+ }
958
+ }
959
+
960
+ // 2. commands/ → ~/.config/opencode/commands/<id>__<cmd>.md
961
+ const commandsDir = join(modDir, 'commands');
962
+ if (existsSync(commandsDir)) {
963
+ for (const f of readdirSync(commandsDir)) {
964
+ if (!f.endsWith('.md')) continue;
965
+ const src = join(commandsDir, f);
966
+ const dest = join(OPENCODE_COMMANDS_DIR, `${modId}__${f}`);
967
+ if (copyFileSafe(src, dest)) counts.commands += 1;
968
+ }
969
+ }
970
+
971
+ // 3. INSTRUCTIONS.md → ~/.opencode/skills/<id>-instructions/SKILL.md
972
+ const instructionsSrc = join(modDir, 'INSTRUCTIONS.md');
973
+ if (existsSync(instructionsSrc)) {
974
+ const destDir = join(OPENCODE_SKILLS_DIR, `${modId}-instructions`);
975
+ const dest = join(destDir, 'SKILL.md');
976
+ if (copyFileSafe(instructionsSrc, dest)) counts.instructions += 1;
977
+ }
978
+
979
+ // 4. skills/<name>/SKILL.md → ~/.opencode/skills/<id>-<name>/SKILL.md
980
+ const skillsDir = join(modDir, 'skills');
981
+ if (existsSync(skillsDir)) {
982
+ let entries;
983
+ try {
984
+ entries = readdirSync(skillsDir, { withFileTypes: true });
985
+ } catch {
986
+ entries = [];
987
+ }
988
+ for (const e of entries) {
989
+ if (!e.isDirectory()) continue;
990
+ const skillMd = join(skillsDir, e.name, 'SKILL.md');
991
+ if (!existsSync(skillMd)) continue;
992
+ const destDir = join(OPENCODE_SKILLS_DIR, `${modId}-${e.name}`);
993
+ const dest = join(destDir, 'SKILL.md');
994
+ if (copyFileSafe(skillMd, dest)) counts.skills += 1;
995
+ }
996
+ }
997
+
998
+ return counts;
999
+ }
1000
+
1001
+ /**
1002
+ * Remove all instruction files installed by a mod. Safe to call when
1003
+ * the mod folder is already gone (uses the same `<id>__` / `<id>-` prefixes
1004
+ * install used, so it only removes what this mod installed).
1005
+ */
1006
+ function uninstallModInstructions(modId) {
1007
+ if (!modId) return { agents: 0, commands: 0, skills: 0, instructions: 0 };
1008
+ const counts = { agents: 0, commands: 0, skills: 0, instructions: 0 };
1009
+
1010
+ // 1. agents/ — remove every <id>__*.md
1011
+ if (existsSync(OPENCODE_AGENTS_DIR)) {
1012
+ let entries;
1013
+ try {
1014
+ entries = readdirSync(OPENCODE_AGENTS_DIR);
1015
+ } catch {
1016
+ entries = [];
1017
+ }
1018
+ for (const f of entries) {
1019
+ if (f.startsWith(`${modId}__`) && f.endsWith('.md')) {
1020
+ if (removeSafe(join(OPENCODE_AGENTS_DIR, f))) counts.agents += 1;
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ // 2. commands/ — remove every <id>__*.md
1026
+ if (existsSync(OPENCODE_COMMANDS_DIR)) {
1027
+ let entries;
1028
+ try {
1029
+ entries = readdirSync(OPENCODE_COMMANDS_DIR);
1030
+ } catch {
1031
+ entries = [];
1032
+ }
1033
+ for (const f of entries) {
1034
+ if (f.startsWith(`${modId}__`) && f.endsWith('.md')) {
1035
+ if (removeSafe(join(OPENCODE_COMMANDS_DIR, f))) counts.commands += 1;
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ // 3. INSTRUCTIONS.md — remove <id>-instructions/ skill
1041
+ const instructionsDest = join(OPENCODE_SKILLS_DIR, `${modId}-instructions`);
1042
+ if (removeSafe(instructionsDest)) counts.instructions += 1;
1043
+
1044
+ // 4. skills/ — remove every <id>-<name>/ skill dir that this mod installed.
1045
+ // We only remove dirs that look like `<id>-*` and aren't the agent-baseline
1046
+ // or other base-installed skills.
1047
+ if (existsSync(OPENCODE_SKILLS_DIR)) {
1048
+ let entries;
1049
+ try {
1050
+ entries = readdirSync(OPENCODE_SKILLS_DIR, { withFileTypes: true });
1051
+ } catch {
1052
+ entries = [];
1053
+ }
1054
+ for (const e of entries) {
1055
+ if (!e.isDirectory()) continue;
1056
+ // Match `<id>-<anything>`. Use the first hyphen as the split so mod
1057
+ // ids that themselves contain hyphens still work (`<id>-<name>`).
1058
+ const prefix = `${modId}-`;
1059
+ if (e.name === `${modId}-instructions`) continue; // handled above
1060
+ if (e.name.startsWith(prefix)) {
1061
+ if (removeSafe(join(OPENCODE_SKILLS_DIR, e.name))) counts.skills += 1;
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ return counts;
1067
+ }