@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,1250 @@
1
+ /**
2
+ * background.ts
3
+ *
4
+ * InstanceManager — owns the in-memory map of background instances and
5
+ * orchestrates the per-instance event handlers (v0.4.2 spec §2.2, §4, §5.4, §6.2).
6
+ *
7
+ * Responsibilities:
8
+ * - `add()` is the single entry point for inserting a new instance. The
9
+ * cap check and the map insertion happen inside one async mutex, so
10
+ * concurrent `add()` calls can never exceed the cap (HIGH-10 / HIGH-12 /
11
+ * HIGH-21 / HIGH-38).
12
+ * - `update()` patches the in-memory state and persists to disk. The
13
+ * per-instance mutex from {@link BackgroundStateStore} serializes
14
+ * concurrent updates to the same instance.
15
+ * - `kill()` and `collect()` operate on the in-memory state; the HTTP
16
+ * calls go through {@link HttpClient}.
17
+ * - `rebuildInMemoryMap()` is called on init (spec §5.4). Any in-flight
18
+ * `running` or `pending` instance is marked `failed` because the
19
+ * serve child is new and the opencode sessions are gone.
20
+ * - `shutdownAll()` is called on `dispose` / SIGTERM. Marks all in-memory
21
+ * instances `failed` with `error: "plugin shutting down"`, aborts
22
+ * each via `POST /session/{id}/abort` (best-effort, 5s timeout per
23
+ * call), then waits for the serve child to exit.
24
+ *
25
+ * Per-instance event handler (spec §4.1, §4.3, §6.2):
26
+ * - For every `EventMessagePartUpdated` of `type: "tool"`, increment
27
+ * `toolCallCount`. If the count reaches the per-instance cap, abort
28
+ * the session and mark the instance `failed` with
29
+ * `error: "Tool-call cap reached (N). Aborted to prevent cost runaway."`.
30
+ * - If the tool part's error matches the loop-guard regex
31
+ * `Loop protection: 12 identical calls to (\S+)`, capture the tool
32
+ * name into `loopGuardTool`, set `error` to the canonical string,
33
+ * and mark the instance `failed`.
34
+ * - For every `EventMessagePartUpdated` of `type: "text"` on an
35
+ * assistant message, refresh `resultPreview` (last 200 chars).
36
+ * - On `EventSessionIdle`, mark the instance `done`.
37
+ * - On `EventSessionError`, mark the instance `failed` with the error.
38
+ *
39
+ * v0.3.0 — stall and thinking-loop protection:
40
+ * - Every event handler updates `lastEventAt` (the "heartbeat"). The
41
+ * stall checker fires every `STALL_CHECK_INTERVAL_MS`; if a non-terminal
42
+ * instance has `now - lastEventAt > backgroundStallTimeoutMs`, the
43
+ * session is aborted and the instance marked `failed`.
44
+ * - `tool` and `text` parts advance `lastToolOrTextAt`. `thinking`
45
+ * parts do NOT advance it; that is the loop indicator.
46
+ * - The thinking-loop checker fires every `STALL_CHECK_INTERVAL_MS`. For
47
+ * a `running` instance with `now - lastToolOrTextAt >
48
+ * backgroundThinkingLoopTimeoutMs`:
49
+ * - If `interventionCount < backgroundMaxInterventions`: send a
50
+ * research-intervention prompt (fire-and-forget) and increment the
51
+ * counter.
52
+ * - Otherwise: abort the session and mark `failed`.
53
+ * - When a `tool` or `text` part arrives after one or more interventions,
54
+ * the counter is reset to 0 (sign of progress). The intervention
55
+ * metadata is cleared so a later status check does not show stale
56
+ * intervention info.
57
+ *
58
+ * "Track BEFORE HTTP" invariant (spec §2.2 / HIGH-21):
59
+ * - The instance is added to the map (status `pending`) BEFORE any HTTP
60
+ * call. If the HTTP call fails, the instance is marked `failed`. The
61
+ * map is never left in a half-state.
62
+ */
63
+
64
+ import type { BackgroundState, BackgroundStateStore, Logger } from "./background-state.js";
65
+ import { TERMINAL_STATUSES } from "./background-state.js";
66
+ import type { HttpClient } from "./http-client.js";
67
+ import type { EventStream, StreamEvent, SessionEventHandler } from "./event-stream.js";
68
+ import type { ServeLifecycle } from "./serve.js";
69
+ import { researchInterventionPrompt } from "./research-prompt.js";
70
+
71
+ // --- Public surface -------------------------------------------------------
72
+
73
+ /** A snapshot of an instance for the `bizar_status` tool. */
74
+ export interface InstanceView {
75
+ instanceId: string;
76
+ agent: string;
77
+ status: BackgroundState["status"];
78
+ startedAt: number;
79
+ completedAt?: number;
80
+ toolCallCount: number;
81
+ promptPreview: string;
82
+ resultPreview?: string;
83
+ error?: string;
84
+ parentAgent: string;
85
+ parentInstanceId?: string;
86
+ sessionId: string;
87
+ // v0.3.0 — stall and thinking-loop protection
88
+ lastEventAt?: number;
89
+ interventionCount?: number;
90
+ interventionAt?: number;
91
+ interventionReason?: string;
92
+ }
93
+
94
+ /** The return shape of `bizar_collect`. */
95
+ export interface CollectResult {
96
+ status: BackgroundState["status"];
97
+ result: string;
98
+ toolCallCount: number;
99
+ durationMs: number;
100
+ error?: string;
101
+ }
102
+
103
+ /** Filter shape for `list()`. */
104
+ export interface InstanceListFilter {
105
+ agent?: string;
106
+ status?: BackgroundState["status"];
107
+ }
108
+
109
+ /** Shape passed to `add()`. The status is forced to `pending` and the
110
+ * startedAt is stamped by the manager. */
111
+ export type AddDraft = Omit<BackgroundState, "status" | "startedAt">;
112
+
113
+ /** Return type of `add()`. `"cap_reached"` is a sentinel for the
114
+ * overshoot path; the populated state is the success path. */
115
+ export type AddResult = BackgroundState | "cap_reached";
116
+
117
+ // --- Constants ------------------------------------------------------------
118
+
119
+ /** Maximum length of `resultPreview` per spec §3.2. */
120
+ const RESULT_PREVIEW_MAX = 200;
121
+ /** Maximum length of `promptPreview` stored in the JSON. */
122
+ const PROMPT_PREVIEW_MAX = 200;
123
+ /** Tool-call cap regex (spec §4.1, NEW-H8 pin). */
124
+ const LOOP_GUARD_RE = /Loop protection: 12 identical calls to (\S+)/;
125
+
126
+ /**
127
+ * How often the stall + thinking-loop checker fires. 15 seconds is short
128
+ * enough to detect stalls within one tick of the default 3-min stall
129
+ * timeout, and long enough that the per-instance mutex is not constantly
130
+ * contested. Spec §v0.3.0.
131
+ */
132
+ const STALL_CHECK_INTERVAL_MS = 15_000;
133
+
134
+ // --- Class ---------------------------------------------------------------
135
+
136
+ /**
137
+ * Manages the in-memory map of background instances. Created once at
138
+ * plugin init; lives for the life of the plugin process.
139
+ */
140
+ export class InstanceManager {
141
+ private instances = new Map<string, BackgroundState>();
142
+ private eventUnsubscribers = new Map<string, () => void>();
143
+ private addLock: Promise<unknown> = Promise.resolve();
144
+ private stateStore: BackgroundStateStore;
145
+ private maxConcurrent: number;
146
+ private toolCallCap: number;
147
+ private logger: Logger;
148
+ // v0.8.0 — `serve`, `http`, `stream` are nullable to support the
149
+ // bg-only mode used when the opencode serve child is unavailable
150
+ // (BIZAR_SERVE_DISABLE=1, startup failure) or when this process IS a
151
+ // bg-spawned `opencode run` subprocess. In bg-only mode, every method
152
+ // that would otherwise call `this.http.X` or `this.stream.X` is a
153
+ // no-op; state transitions still happen via the runner's `onExit`
154
+ // callback (see src/tools/bg-spawn.ts).
155
+ private serve: ServeLifecycle | null;
156
+ private http: HttpClient | null;
157
+ private stream: EventStream | null;
158
+ private worktree: string;
159
+ // v0.3.0 — stall and thinking-loop protection
160
+ private stallTimeoutMs: number;
161
+ private thinkingLoopTimeoutMs: number;
162
+ private maxInterventions: number;
163
+ /** Interval handle for the periodic stall + thinking-loop checker. */
164
+ private stallCheckerTimer: ReturnType<typeof setInterval> | null = null;
165
+ /** Guard so tests can disable the interval without monkey-patching. */
166
+ private stallCheckerDisabled = false;
167
+
168
+ constructor(opts: {
169
+ stateStore: BackgroundStateStore;
170
+ maxConcurrent: number;
171
+ toolCallCap: number;
172
+ logger: Logger;
173
+ // v0.8.0 — `worktree` is now an explicit param (was previously
174
+ // derived from `opts.serve.worktree`, which is impossible when
175
+ // `serve` is null in bg-only mode).
176
+ worktree: string;
177
+ serve: ServeLifecycle | null;
178
+ http: HttpClient | null;
179
+ stream: EventStream | null;
180
+ // v0.3.0
181
+ stallTimeoutMs?: number;
182
+ thinkingLoopTimeoutMs?: number;
183
+ maxInterventions?: number;
184
+ }) {
185
+ this.stateStore = opts.stateStore;
186
+ this.maxConcurrent = Math.max(1, Math.floor(opts.maxConcurrent));
187
+ this.toolCallCap = Math.max(1, Math.floor(opts.toolCallCap));
188
+ this.logger = opts.logger;
189
+ this.serve = opts.serve;
190
+ this.http = opts.http;
191
+ this.stream = opts.stream;
192
+ this.worktree = opts.worktree;
193
+ this.stallTimeoutMs = Math.max(
194
+ 1_000,
195
+ Math.floor(opts.stallTimeoutMs ?? 180_000),
196
+ );
197
+ this.thinkingLoopTimeoutMs = Math.max(
198
+ 1_000,
199
+ Math.floor(opts.thinkingLoopTimeoutMs ?? 300_000),
200
+ );
201
+ this.maxInterventions = Math.max(1, Math.floor(opts.maxInterventions ?? 1));
202
+ // Schedule the periodic stall + thinking-loop checker ONLY when we
203
+ // have a working HTTP client. In bg-only mode the runner owns the
204
+ // subprocess lifecycle, so the checker has nothing to do.
205
+ if (this.http !== null) {
206
+ this.stallCheckerTimer = setInterval(
207
+ () => void this.runStallAndLoopChecks(),
208
+ STALL_CHECK_INTERVAL_MS,
209
+ );
210
+ this.stallCheckerTimer.unref?.();
211
+ }
212
+ }
213
+
214
+ /** True iff the manager was constructed without an HTTP client (no
215
+ * opencode serve child reachable). HTTP-dependent operations are
216
+ * no-ops in this mode. */
217
+ get isBgOnly(): boolean {
218
+ return this.http === null;
219
+ }
220
+
221
+ // --- Getters ------------------------------------------------------------
222
+
223
+ get size(): number {
224
+ return this.instances.size;
225
+ }
226
+
227
+ /** Current stall timeout (ms). Exposed for tests. */
228
+ get stallTimeoutMsValue(): number {
229
+ return this.stallTimeoutMs;
230
+ }
231
+
232
+ /** Current thinking-loop timeout (ms). Exposed for tests. */
233
+ get thinkingLoopTimeoutMsValue(): number {
234
+ return this.thinkingLoopTimeoutMs;
235
+ }
236
+
237
+ /** Current max interventions. Exposed for tests. */
238
+ get maxInterventionsValue(): number {
239
+ return this.maxInterventions;
240
+ }
241
+
242
+ /**
243
+ * Disable the periodic stall + thinking-loop checker. Used by tests
244
+ * that want to call `runStallAndLoopChecks()` directly without racing
245
+ * the interval. Idempotent.
246
+ */
247
+ disablePeriodicChecks(): void {
248
+ this.stallCheckerDisabled = true;
249
+ if (this.stallCheckerTimer !== null) {
250
+ clearInterval(this.stallCheckerTimer);
251
+ this.stallCheckerTimer = null;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Run one iteration of the stall + thinking-loop checker. Public so
257
+ * tests can invoke it deterministically. Production code drives this
258
+ * via the `setInterval` registered in the constructor.
259
+ */
260
+ async runStallAndLoopChecks(): Promise<void> {
261
+ if (this.stallCheckerDisabled) return;
262
+ // v0.8.0 — bg-only mode has no HTTP client, so stall and
263
+ // intervention are no-ops. The constructor also skips registering
264
+ // the interval in this mode, but a stray call from a test or future
265
+ // caller must still be safe.
266
+ if (this.http === null) return;
267
+ // Snapshot the instance ids so we do not iterate while the map mutates.
268
+ const ids: string[] = [];
269
+ for (const inst of this.instances.values()) {
270
+ if (TERMINAL_STATUSES.has(inst.status)) continue;
271
+ ids.push(inst.instanceId);
272
+ }
273
+ for (const id of ids) {
274
+ const inst = this.instances.get(id);
275
+ if (!inst || TERMINAL_STATUSES.has(inst.status)) continue;
276
+ const now = Date.now();
277
+ // `lastEventAt` / `lastToolOrTextAt` are seeded by `add()` and
278
+ // backfilled in `readState`, so they are guaranteed to be set on
279
+ // any instance that ever reached this method. We coalesce with
280
+ // `?? 0` because TS strict mode treats the schema field as
281
+ // optional — the value is informational in the rare case where
282
+ // it is missing (an old or corrupt state file).
283
+ const lastEventAt = inst.lastEventAt ?? 0;
284
+ const lastToolOrTextAt = inst.lastToolOrTextAt ?? 0;
285
+ // Stall check fires first; it is the more severe failure.
286
+ if (now - lastEventAt > this.stallTimeoutMs) {
287
+ await this._abortAsStalled(inst);
288
+ continue;
289
+ }
290
+ // Thinking-loop check applies to `running` instances only. A
291
+ // `pending` instance has not yet started generating, so it is not
292
+ // a candidate for the loop detector.
293
+ if (inst.status === "running") {
294
+ const since = now - lastToolOrTextAt;
295
+ if (since > this.thinkingLoopTimeoutMs) {
296
+ const currentCount = inst.interventionCount ?? 0;
297
+ if (currentCount < this.maxInterventions) {
298
+ await this._sendIntervention(inst, since);
299
+ } else {
300
+ await this._abortAsThinkingLoop(inst, since);
301
+ }
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ // --- Internal dispatch (shared by add() and restart()) ------------------
308
+
309
+ /**
310
+ * Insert a fully-constructed BackgroundState into the in-memory map
311
+ * and persist to disk asynchronously. Does NOT check the concurrency
312
+ * cap — the caller handles that.
313
+ *
314
+ * This is extracted from `add()` so `restart()` can reuse the same
315
+ * insert-and-persist path without duplicating the logic.
316
+ */
317
+ private dispatchInternal(full: BackgroundState): BackgroundState {
318
+ this.instances.set(full.instanceId, full);
319
+ // Persist asynchronously; failure is logged but does not roll back
320
+ // the in-memory insert (the instance is "tracked" either way).
321
+ this.stateStore.save(full).catch((err: unknown) => {
322
+ this.logger.warn(
323
+ `bizar: failed to persist new instance ${full.instanceId}: ${
324
+ err instanceof Error ? err.message : String(err)
325
+ }`,
326
+ );
327
+ });
328
+ return full;
329
+ }
330
+
331
+ // --- Atomic add (spec §2.2) ---------------------------------------------
332
+
333
+ /**
334
+ * Add a new instance. The cap check and the map insertion are inside
335
+ * one async mutex — no half-state on overshoot. Returns `"cap_reached"`
336
+ * on overshoot; the full `BackgroundState` on success.
337
+ */
338
+ async add(draft: AddDraft): Promise<AddResult> {
339
+ return (await (this.addLock = this.addLock.then(async () => {
340
+ // Count "live" instances: anything not yet terminal.
341
+ let live = 0;
342
+ for (const inst of this.instances.values()) {
343
+ if (!TERMINAL_STATUSES.has(inst.status)) live += 1;
344
+ }
345
+ if (live >= this.maxConcurrent) {
346
+ this.logger.warn(
347
+ `bizar: max concurrent instances reached (${this.maxConcurrent}); rejecting add`,
348
+ );
349
+ return "cap_reached" as const;
350
+ }
351
+ const now = Date.now();
352
+ const full: BackgroundState = {
353
+ ...draft,
354
+ status: "pending",
355
+ startedAt: now,
356
+ toolCallCount: draft.toolCallCount ?? 0,
357
+ // Trim the prompt preview so the JSON stays small.
358
+ promptPreview: (draft.promptPreview ?? "").slice(0, PROMPT_PREVIEW_MAX),
359
+ // v0.3.0 — seed the liveness timestamps so the stall and
360
+ // thinking-loop checkers have a baseline. We seed BOTH from
361
+ // `startedAt` so a freshly-spawned instance is not immediately
362
+ // flagged as stalled while the session is still being created
363
+ // (the first event typically arrives within seconds).
364
+ lastEventAt: now,
365
+ lastToolOrTextAt: now,
366
+ interventionCount: 0,
367
+ };
368
+ this.dispatchInternal(full);
369
+ // BUGFIX (v0.5.1): Do NOT call attachEventHandler() here. The
370
+ // instance was just added with sessionId="" (filled in later by
371
+ // POST /session). EventStream.onSessionEvent rejects empty strings,
372
+ // so attaching here threw and the spawn failed before the HTTP
373
+ // call could run. Callers must call attachEventHandler() explicitly
374
+ // after the real sessionId is known. See test in
375
+ // tests/background.test.ts "add() does not attach event handler
376
+ // (empty sessionId)".
377
+ return full;
378
+ }))) as AddResult;
379
+ }
380
+
381
+ // --- Read access --------------------------------------------------------
382
+
383
+ /**
384
+ * Look up an instance by id. Returns null if not found.
385
+ */
386
+ async get(instanceId: string): Promise<BackgroundState | null> {
387
+ return this.instances.get(instanceId) ?? null;
388
+ }
389
+
390
+ /**
391
+ * Snapshot of in-memory instances, filtered. Used by `bizar_status`.
392
+ */
393
+ async list(filter?: InstanceListFilter): Promise<InstanceView[]> {
394
+ const out: InstanceView[] = [];
395
+ for (const inst of this.instances.values()) {
396
+ if (filter?.agent && inst.agent !== filter.agent) continue;
397
+ if (filter?.status && inst.status !== filter.status) continue;
398
+ out.push(toView(inst));
399
+ }
400
+ // Sort by startedAt ascending so callers see the oldest first.
401
+ out.sort((a, b) => a.startedAt - b.startedAt);
402
+ return out;
403
+ }
404
+
405
+ // --- Update -------------------------------------------------------------
406
+
407
+ /**
408
+ * Patch an instance in-memory and persist. Returns silently if the
409
+ * instance is not found. Mutations that would set a terminal state
410
+ * stamp `completedAt` automatically.
411
+ */
412
+ async update(instanceId: string, patch: Partial<BackgroundState>): Promise<void> {
413
+ const inst = this.instances.get(instanceId);
414
+ if (!inst) return;
415
+ await this.stateStore.withLock(instanceId, async () => {
416
+ const current = this.instances.get(instanceId);
417
+ if (!current) return;
418
+ Object.assign(current, patch);
419
+ if (TERMINAL_STATUSES.has(patch.status ?? current.status) && !current.completedAt) {
420
+ current.completedAt = Date.now();
421
+ }
422
+ if (TERMINAL_STATUSES.has(current.status)) {
423
+ this.detachEventHandler(instanceId);
424
+ }
425
+ try {
426
+ await this.stateStore.saveUnlocked(current);
427
+ } catch (err: unknown) {
428
+ this.logger.warn(
429
+ `bizar: failed to persist update for ${instanceId}: ${
430
+ err instanceof Error ? err.message : String(err)
431
+ }`,
432
+ );
433
+ }
434
+ });
435
+ }
436
+
437
+ // --- Kill ---------------------------------------------------------------
438
+
439
+ /**
440
+ * Abort the opencode session and mark the instance `killed`. If the
441
+ * instance is already in a terminal state, this is a no-op (spec §1.5,
442
+ * MEDIUM-40).
443
+ */
444
+ async kill(instanceId: string): Promise<void> {
445
+ const inst = this.instances.get(instanceId);
446
+ if (!inst) return;
447
+ if (TERMINAL_STATUSES.has(inst.status)) {
448
+ this.logger.debug(
449
+ `bizar: kill(${instanceId}) is a no-op (status=${inst.status})`,
450
+ );
451
+ return;
452
+ }
453
+ // v0.8.0 — bg-only mode has no HTTP client. The subprocess is
454
+ // already owned by opencode-runner.ts (see src/tools/bg-spawn.ts);
455
+ // mark the instance killed in-memory and let the runner notice the
456
+ // status change when the process eventually exits. We do NOT try
457
+ // to kill the OS process from here — that's the runner's job and
458
+ // we don't have a clean processId reference in bg-only mode
459
+ // (the runner does, but it lives in a separate module).
460
+ if (this.http === null) {
461
+ this.logger.warn(
462
+ `bizar: kill(${instanceId}) in bg-only mode: marking killed; subprocess will be reaped by opencode-runner.ts on exit`,
463
+ );
464
+ await this.update(instanceId, {
465
+ status: "killed",
466
+ completedAt: Date.now(),
467
+ });
468
+ return;
469
+ }
470
+ // Abort the opencode session. The next SSE event for this session
471
+ // (EventSessionIdle or EventSessionError) will finalize the status.
472
+ const abort = await this.http.abortSession(inst.sessionId, this.worktree);
473
+ if (!abort.ok) {
474
+ this.logger.warn(
475
+ `bizar: kill(${instanceId}): abort failed: ${abort.error}`,
476
+ );
477
+ // Even if the abort call failed, we still want the in-memory state
478
+ // to reflect a deliberate kill so the user sees it. The next SSE
479
+ // event will overwrite if it disagrees.
480
+ }
481
+ await this.update(instanceId, {
482
+ status: "killed",
483
+ completedAt: Date.now(),
484
+ });
485
+ this.logger.info(`bizar: killed background instance ${instanceId}`);
486
+ }
487
+
488
+ // --- Restart (v0.5.5 — persistent auto-restart) ------------------------
489
+
490
+ /**
491
+ * Re-spawn a failed persistent instance with the same prompt, agent,
492
+ * and model config. The new instance gets a fresh `instanceId` and is
493
+ * linked to the original via `parentInstanceId`.
494
+ *
495
+ * Returns `{ ok: true, newInstanceId }` on success, or
496
+ * `{ ok: false, error: "..." }` on failure. Does NOT check the
497
+ * concurrency cap — the original instance is terminal, so its slot
498
+ * is already freed.
499
+ */
500
+ async restart(instanceId: string): Promise<{
501
+ ok: boolean;
502
+ newInstanceId?: string;
503
+ error?: string;
504
+ }> {
505
+ const existing = this.instances.get(instanceId);
506
+ if (!existing) return { ok: false, error: "instance_not_found" };
507
+ const totalRestarts = this.getTotalRestartCount(instanceId);
508
+ if (totalRestarts >= (existing.maxRestarts ?? 3)) {
509
+ return { ok: false, error: "max_restarts_reached" };
510
+ }
511
+
512
+ const now = Date.now();
513
+ const newInstanceId = generateInstanceId();
514
+ const full: BackgroundState = {
515
+ instanceId: newInstanceId,
516
+ sessionId: "",
517
+ agent: existing.agent,
518
+ model: existing.model,
519
+ promptPreview: (existing.prompt ?? existing.promptPreview ?? "").slice(0, PROMPT_PREVIEW_MAX),
520
+ prompt: existing.prompt,
521
+ parentAgent: existing.parentAgent,
522
+ parentInstanceId: instanceId,
523
+ logPath: `${this.worktree}/.opencode/log/${newInstanceId}.log`,
524
+ timeoutMs: existing.timeoutMs,
525
+ toolCallCount: 0,
526
+ // v0.5.5 — persist the auto-restart fields
527
+ persistent: existing.persistent,
528
+ maxRestarts: existing.maxRestarts,
529
+ restartCount: (existing.restartCount ?? 0) + 1,
530
+ status: "pending",
531
+ startedAt: now,
532
+ lastEventAt: now,
533
+ lastToolOrTextAt: now,
534
+ interventionCount: 0,
535
+ };
536
+
537
+ this.dispatchInternal(full);
538
+ this.logger.info(
539
+ `bizar: restarted instance ${instanceId} as ${newInstanceId} (restart #${full.restartCount})`,
540
+ );
541
+ return { ok: true, newInstanceId };
542
+ }
543
+
544
+ /**
545
+ * Walk the parent chain to compute the true restart count for this
546
+ * instance (Forseti C4). `restartCount` alone only reflects the child's
547
+ * own counter, so a chain of restarts can exceed `maxRestarts`. This
548
+ * helper sums the counters across the entire chain (parent + all
549
+ * descendants) and returns the total.
550
+ */
551
+ private getTotalRestartCount(instanceId: string): number {
552
+ let current = this.instances.get(instanceId);
553
+ if (!current) return 0;
554
+ let count = current.restartCount ?? 0;
555
+ let parentId = current.parentInstanceId;
556
+ const visited = new Set<string>([instanceId]);
557
+ while (parentId && !visited.has(parentId)) {
558
+ visited.add(parentId);
559
+ const parent = this.instances.get(parentId);
560
+ if (!parent) break;
561
+ count += parent.restartCount ?? 0;
562
+ parentId = parent.parentInstanceId;
563
+ }
564
+ return count;
565
+ }
566
+
567
+ // --- Collect ------------------------------------------------------------
568
+
569
+ /**
570
+ * Wait for an instance to reach a terminal status, or until `deadline`
571
+ * (ms epoch) is reached. Returns `true` on terminal, `false` on
572
+ * timeout.
573
+ *
574
+ * Two implementations:
575
+ * - **With HTTP+stream** (full mode): subscribe to the SSE session
576
+ * event stream AND poll the in-memory map. SSE gives sub-second
577
+ * resolution; the in-memory check covers terminal states we set
578
+ * ourselves (tool-call cap, loop guard, intervention abort).
579
+ * - **Bg-only mode** (no HTTP, no SSE): poll the in-memory map
580
+ * every 500 ms. Terminal transitions come from the runner's
581
+ * `onExit` callback (see src/tools/bg-spawn.ts) which updates
582
+ * the instance state directly.
583
+ */
584
+ private async waitForTerminal(
585
+ instanceId: string,
586
+ deadline: number,
587
+ ): Promise<boolean> {
588
+ // Already terminal?
589
+ const initial = this.instances.get(instanceId);
590
+ if (initial && TERMINAL_STATUSES.has(initial.status)) return true;
591
+
592
+ if (this.stream === null || initial === undefined) {
593
+ // Bg-only path: poll the in-memory map.
594
+ const POLL_MS = 500;
595
+ while (Date.now() < deadline) {
596
+ await new Promise<void>((resolve) => setTimeout(resolve, POLL_MS));
597
+ const cur = this.instances.get(instanceId);
598
+ if (!cur) return false;
599
+ if (TERMINAL_STATUSES.has(cur.status)) return true;
600
+ }
601
+ return false;
602
+ }
603
+
604
+ // Full path: subscribe to the session event stream AND observe our
605
+ // own in-memory state changes for terminal transitions we set
606
+ // ourselves (tool-cap, loop guard, intervention abort).
607
+ return await new Promise<boolean>((resolve) => {
608
+ const remaining = Math.max(0, deadline - Date.now());
609
+ if (remaining === 0) {
610
+ resolve(false);
611
+ return;
612
+ }
613
+ const timer = setTimeout(() => {
614
+ unsubscribe();
615
+ resolve(false);
616
+ }, remaining);
617
+ const unsubscribe = this.stream!.onSessionEvent(
618
+ initial.sessionId,
619
+ (ev) => {
620
+ if (ev.type === "session.idle" || ev.type === "session.error") {
621
+ clearTimeout(timer);
622
+ unsubscribe();
623
+ resolve(true);
624
+ return;
625
+ }
626
+ const cur = this.instances.get(instanceId);
627
+ if (cur && TERMINAL_STATUSES.has(cur.status)) {
628
+ clearTimeout(timer);
629
+ unsubscribe();
630
+ resolve(true);
631
+ }
632
+ },
633
+ );
634
+ const cur = this.instances.get(instanceId);
635
+ if (cur && TERMINAL_STATUSES.has(cur.status)) {
636
+ clearTimeout(timer);
637
+ unsubscribe();
638
+ resolve(true);
639
+ }
640
+ });
641
+ }
642
+
643
+ /**
644
+ * Wait for the instance to reach a terminal state (or until
645
+ * `timeoutMs` elapses), then build the result string per spec §4.4.
646
+ *
647
+ * If the instance is already terminal on entry, we skip the wait and
648
+ * go straight to result construction.
649
+ */
650
+ async collect(instanceId: string, timeoutMs: number): Promise<CollectResult> {
651
+ const inst = this.instances.get(instanceId);
652
+ if (!inst) {
653
+ throw new Error(`collect: instance ${instanceId} not found`);
654
+ }
655
+ const startedAt = inst.startedAt;
656
+ const deadline = Date.now() + Math.max(0, timeoutMs);
657
+
658
+ // 1. Wait for terminal state.
659
+ if (!TERMINAL_STATUSES.has(inst.status)) {
660
+ const reachedTerminal = await this.waitForTerminal(instanceId, deadline);
661
+ if (!reachedTerminal) {
662
+ // Timed out. Return what we have.
663
+ const final = this.instances.get(instanceId);
664
+ if (final && !TERMINAL_STATUSES.has(final.status)) {
665
+ await this.update(instanceId, {
666
+ status: "timed_out",
667
+ completedAt: Date.now(),
668
+ });
669
+ }
670
+ const dur = Date.now() - startedAt;
671
+ const final2 = this.instances.get(instanceId);
672
+ const out: CollectResult = {
673
+ status: final2?.status ?? "timed_out",
674
+ result: final2?.resultPreview ?? "",
675
+ toolCallCount: final2?.toolCallCount ?? 0,
676
+ durationMs: dur,
677
+ error: `collect timed out after ${timeoutMs}ms`,
678
+ };
679
+ return out;
680
+ }
681
+ }
682
+
683
+ // 2. Build the result. Fetch messages from the opencode server and
684
+ // concatenate the assistant text parts. In bg-only mode there is
685
+ // no HTTP client to ask, so we fall back to whatever
686
+ // `resultPreview` was captured during the run (often empty
687
+ // because there is no SSE stream in bg-only mode — the runner
688
+ // writes the raw output to the log file instead).
689
+ const final = this.instances.get(instanceId);
690
+ if (!final) {
691
+ throw new Error(`collect: instance ${instanceId} disappeared`);
692
+ }
693
+ const resultText = await this.buildResultText(final);
694
+ const dur = (final.completedAt ?? Date.now()) - startedAt;
695
+ const out: CollectResult = {
696
+ status: final.status,
697
+ result: resultText,
698
+ toolCallCount: final.toolCallCount,
699
+ durationMs: dur,
700
+ };
701
+ if (final.error !== undefined) out.error = final.error;
702
+ return out;
703
+ }
704
+
705
+ // --- Rebuild on init (spec §5.4) ----------------------------------------
706
+
707
+ /**
708
+ * Scan the bg directory, load every instance, and rebuild the in-memory
709
+ * map. Any `running` or `pending` instance is marked `failed` because
710
+ * the serve child is new and the opencode sessions are gone.
711
+ * Historical records (done, failed, killed, timed_out) are preserved.
712
+ */
713
+ async rebuildInMemoryMap(): Promise<void> {
714
+ let all: BackgroundState[];
715
+ try {
716
+ all = await this.stateStore.list();
717
+ } catch (err: unknown) {
718
+ this.logger.warn(
719
+ `bizar: rebuildInMemoryMap: list() failed: ${
720
+ err instanceof Error ? err.message : String(err)
721
+ }`,
722
+ );
723
+ return;
724
+ }
725
+ let rebuilt = 0;
726
+ let failed = 0;
727
+ for (const inst of all) {
728
+ this.instances.set(inst.instanceId, inst);
729
+ rebuilt += 1;
730
+ if (inst.status === "running" || inst.status === "pending") {
731
+ const message =
732
+ inst.status === "pending"
733
+ ? "plugin restarted while instance was pending"
734
+ : "plugin restarted; serve child is new";
735
+ await this.update(inst.instanceId, {
736
+ status: "failed",
737
+ error: message,
738
+ completedAt: Date.now(),
739
+ });
740
+ failed += 1;
741
+ }
742
+ }
743
+ if (rebuilt > 0) {
744
+ this.logger.info(
745
+ `bizar: rebuilt in-memory map (${rebuilt} instances, ${failed} marked failed)`,
746
+ );
747
+ }
748
+ }
749
+
750
+ // --- Shutdown (spec §5.3) ----------------------------------------------
751
+
752
+ /**
753
+ * Mark all in-memory instances as failed with `error: "plugin shutting down"`,
754
+ * abort all running sessions best-effort (5s timeout per call, in
755
+ * parallel), then return. The serve child termination is the
756
+ * caller's responsibility.
757
+ *
758
+ * Also clears the v0.3.0 stall-checker interval. After `shutdownAll`,
759
+ * the manager is effectively inert — no more periodic checks will
760
+ * fire even though the InstanceManager object itself is still alive.
761
+ */
762
+ async shutdownAll(): Promise<void> {
763
+ // v0.3.0 — clear the periodic checker first so it does not race
764
+ // the in-flight updates below.
765
+ if (this.stallCheckerTimer !== null) {
766
+ clearInterval(this.stallCheckerTimer);
767
+ this.stallCheckerTimer = null;
768
+ }
769
+ this.stallCheckerDisabled = true;
770
+ const live: BackgroundState[] = [];
771
+ for (const inst of this.instances.values()) {
772
+ if (!TERMINAL_STATUSES.has(inst.status)) {
773
+ live.push(inst);
774
+ }
775
+ }
776
+ // Phase 1: mark failed first (spec §5.3 step 1).
777
+ for (const inst of live) {
778
+ await this.update(inst.instanceId, {
779
+ status: "failed",
780
+ error: "plugin shutting down",
781
+ completedAt: Date.now(),
782
+ });
783
+ }
784
+ // Phase 2: best-effort aborts in parallel, 5s per call. In bg-only
785
+ // mode there is no HTTP client to ask — the runner owns the
786
+ // subprocess lifecycle, so the in-memory status flip is the only
787
+ // signal we can emit. Skipped cleanly when http is null.
788
+ if (this.http !== null) {
789
+ const abortPromises = live.map((inst) =>
790
+ withTimeout(
791
+ this.http!.abortSession(inst.sessionId, this.worktree),
792
+ 5_000,
793
+ ).catch(() => undefined),
794
+ );
795
+ await Promise.allSettled(abortPromises);
796
+ }
797
+ this.logger.info(`bizar: shutdownAll complete (${live.length} instances aborted)`);
798
+ }
799
+
800
+ // --- v0.3.0 stall and thinking-loop helpers ----------------------------
801
+
802
+ /**
803
+ * Mark an instance `failed` with the canonical stall message and
804
+ * fire-and-forget the opencode abort call. The stall timeout is
805
+ * intentionally short enough that an abort that fails gracefully
806
+ * (the serve child is dead, etc.) does not leave the user waiting.
807
+ */
808
+ private async _abortAsStalled(inst: BackgroundState): Promise<void> {
809
+ const lastEventAt = inst.lastEventAt ?? 0;
810
+ const sinceMs = Date.now() - lastEventAt;
811
+ this.logger.warn(
812
+ `bizar: instance ${inst.instanceId} stalled (no event for ${sinceMs}ms); aborting`,
813
+ );
814
+ // Fire-and-forget. If the serve child is dead, this returns a
815
+ // failure result but we still mark the instance failed in-memory.
816
+ // v0.8.0 — bg-only mode has no HTTP client; the abort is a no-op
817
+ // there (the runner owns the subprocess).
818
+ if (this.http !== null) {
819
+ this.http
820
+ .abortSession(inst.sessionId, this.worktree)
821
+ .catch(() => undefined);
822
+ }
823
+ await this.update(inst.instanceId, {
824
+ status: "failed",
825
+ error: `No activity for ${this.stallTimeoutMs}ms — LLM appears stalled`,
826
+ completedAt: Date.now(),
827
+ });
828
+ // v0.5.5 — persistent auto-restart on stall
829
+ await this._maybeAutoRestart(inst.instanceId);
830
+ }
831
+
832
+ /**
833
+ * Send the research-intervention prompt to the running session. The
834
+ * message interrupts the current generation and starts a new turn
835
+ * with the prompt as the next user message. This is fire-and-forget:
836
+ * we do not wait for the prompt to complete, only for the HTTP call
837
+ * to return.
838
+ */
839
+ private async _sendIntervention(
840
+ inst: BackgroundState,
841
+ sinceMs: number,
842
+ ): Promise<void> {
843
+ const messageID = generateMessageId();
844
+ const prompt = researchInterventionPrompt(sinceMs);
845
+ const currentCount = inst.interventionCount ?? 0;
846
+ this.logger.warn(
847
+ `bizar: instance ${inst.instanceId} thinking loop (${sinceMs}ms without tool/text); sending intervention #${currentCount + 1}/${this.maxInterventions}`,
848
+ );
849
+ try {
850
+ // v0.8.0 — bg-only mode has no HTTP client; interventions are a
851
+ // no-op there (the runner doesn't expose a "send a user message
852
+ // mid-run" hook). Logged as a debug so the operator can see why
853
+ // no intervention went out.
854
+ if (this.http === null) {
855
+ this.logger.debug(
856
+ `bizar: skipping intervention for ${inst.sessionId} (bg-only mode; no HTTP client)`,
857
+ );
858
+ } else {
859
+ await this.http.sendPrompt(
860
+ {
861
+ sessionId: inst.sessionId,
862
+ messageID,
863
+ agent: inst.agent,
864
+ parts: [{ type: "text", text: prompt }],
865
+ },
866
+ this.worktree,
867
+ );
868
+ }
869
+ } catch (err: unknown) {
870
+ // We swallow the error: the periodic checker will try again next
871
+ // tick. The intervention counter is still incremented below so
872
+ // we eventually escalate to an abort if the prompt keeps failing.
873
+ this.logger.warn(
874
+ `bizar: intervention prompt send failed for ${inst.instanceId}: ${
875
+ err instanceof Error ? err.message : String(err)
876
+ }`,
877
+ );
878
+ }
879
+ const reason = `thinking loop (${formatDuration(sinceMs)} without tool/text)`;
880
+ await this.update(inst.instanceId, {
881
+ interventionCount: currentCount + 1,
882
+ interventionAt: Date.now(),
883
+ interventionReason: reason,
884
+ // Bumping lastEventAt here is intentional: the intervention call
885
+ // counted as an HTTP-driven activity, so the stall checker does
886
+ // not fire immediately after.
887
+ lastEventAt: Date.now(),
888
+ });
889
+ }
890
+
891
+ /**
892
+ * Mark an instance `failed` with the canonical thinking-loop message
893
+ * and fire-and-forget the abort call.
894
+ */
895
+ private async _abortAsThinkingLoop(
896
+ inst: BackgroundState,
897
+ sinceMs: number,
898
+ ): Promise<void> {
899
+ this.logger.warn(
900
+ `bizar: instance ${inst.instanceId} thinking loop exhausted ${this.maxInterventions} intervention(s) over ${sinceMs}ms; aborting`,
901
+ );
902
+ // v0.8.0 — bg-only mode has no HTTP client; the abort is a no-op
903
+ // there (the runner owns the subprocess).
904
+ if (this.http !== null) {
905
+ this.http
906
+ .abortSession(inst.sessionId, this.worktree)
907
+ .catch(() => undefined);
908
+ }
909
+ await this.update(inst.instanceId, {
910
+ status: "failed",
911
+ error: `Thinking loop detected: ${formatDuration(sinceMs)} of thinking without tool calls or output. Spawn a Mimir agent for research.`,
912
+ completedAt: Date.now(),
913
+ });
914
+ // v0.5.5 — persistent auto-restart on thinking-loop exhaustion
915
+ await this._maybeAutoRestart(inst.instanceId);
916
+ }
917
+
918
+ // --- Internal: per-session event handler -------------------------------
919
+
920
+ public attachEventHandler(inst: BackgroundState): () => void {
921
+ this.detachEventHandler(inst.instanceId);
922
+ // v0.8.0 — bg-only mode has no EventStream; return a no-op unsubscriber.
923
+ if (!this.stream) return () => {};
924
+ const handler: SessionEventHandler = (ev: StreamEvent) => {
925
+ void this.handleInstanceEvent(inst.instanceId, ev);
926
+ };
927
+ const unsubscribe = this.stream.onSessionEvent(inst.sessionId, handler);
928
+ this.eventUnsubscribers.set(inst.instanceId, unsubscribe);
929
+ return unsubscribe;
930
+ }
931
+
932
+ private detachEventHandler(instanceId: string): void {
933
+ const unsubscribe = this.eventUnsubscribers.get(instanceId);
934
+ if (!unsubscribe) return;
935
+ try {
936
+ unsubscribe();
937
+ } catch {
938
+ // ignore
939
+ }
940
+ this.eventUnsubscribers.delete(instanceId);
941
+ }
942
+
943
+ private async handleInstanceEvent(
944
+ instanceId: string,
945
+ ev: StreamEvent,
946
+ ): Promise<void> {
947
+ const inst = this.instances.get(instanceId);
948
+ if (!inst) return;
949
+ // Already terminal — ignore further events (e.g., after kill, an
950
+ // EventSessionError may still arrive).
951
+ if (TERMINAL_STATUSES.has(inst.status)) return;
952
+
953
+ // v0.3.0 — every event advances the heartbeat. We do this BEFORE
954
+ // any further work (or inside the per-instance mutex in update())
955
+ // so the stall checker sees the freshest timestamp regardless of
956
+ // how the rest of the handler proceeds.
957
+ inst.lastEventAt = Date.now();
958
+
959
+ if (ev.type === "message.part.updated") {
960
+ await this.onPartUpdated(instanceId, ev);
961
+ } else if (ev.type === "session.idle") {
962
+ await this.update(instanceId, {
963
+ status: "done",
964
+ completedAt: Date.now(),
965
+ });
966
+ } else if (ev.type === "session.error") {
967
+ const errMsg = ev.error ?? "session error";
968
+ await this.update(instanceId, {
969
+ status: "failed",
970
+ error: errMsg,
971
+ completedAt: Date.now(),
972
+ });
973
+ // v0.5.5 — persistent auto-restart. If the instance is persistent
974
+ // and was not explicitly killed, try to restart.
975
+ if (inst.persistent && inst.status !== "killed") {
976
+ await this._maybeAutoRestart(instanceId);
977
+ }
978
+ }
979
+ }
980
+
981
+ /**
982
+ * v0.8.0 — Public version of `_maybeAutoRestart`. The opencode-runner
983
+ * (see src/opencode-runner.ts) calls this from its onExit callback
984
+ * when a `bizar_spawn_background` subprocess exits. Without this,
985
+ * persistent instances would never auto-restart under the new
986
+ * subprocess-based path (the SSE event handler that previously
987
+ * triggered auto-restart is no longer wired up because we don't
988
+ * subscribe to per-session events anymore).
989
+ */
990
+ async maybeAutoRestart(instanceId: string): Promise<void> {
991
+ await this._maybeAutoRestart(instanceId);
992
+ }
993
+
994
+ /** v0.5.5 — Attempt an auto-restart for a persistent failed instance. */
995
+ private async _maybeAutoRestart(instanceId: string): Promise<void> {
996
+ const inst = this.instances.get(instanceId);
997
+ if (!inst || !inst.persistent) return;
998
+ if (inst.status === "killed") return; // user killed it — do not restart
999
+ this.logger.info(
1000
+ `bizar: persistent instance ${instanceId} failed; auto-restarting`,
1001
+ );
1002
+ const result = await this.restart(instanceId);
1003
+ if (result.ok) {
1004
+ // Clear any previous restartError on the parent so the operator
1005
+ // sees a clean state.
1006
+ await this.update(instanceId, { restartError: undefined });
1007
+ this.logger.info(
1008
+ `bizar: auto-restart complete: ${instanceId} -> ${result.newInstanceId}`,
1009
+ );
1010
+ } else {
1011
+ // Persist a human-readable error so the operator can see why the
1012
+ // restart was rejected (e.g. max_restarts_reached).
1013
+ await this.update(instanceId, { restartError: result.error || "unknown" });
1014
+ this.logger.warn(
1015
+ `bizar: auto-restart failed for ${instanceId}: ${result.error}`,
1016
+ );
1017
+ }
1018
+ }
1019
+
1020
+ private async onPartUpdated(
1021
+ instanceId: string,
1022
+ ev: Extract<StreamEvent, { type: "message.part.updated" }>,
1023
+ ): Promise<void> {
1024
+ const inst = this.instances.get(instanceId);
1025
+ if (!inst) return;
1026
+ const part = ev.part;
1027
+
1028
+ // v0.3.0 — tool and text parts advance the "progress" timestamp.
1029
+ // `thinking` parts do NOT, because that is the loop indicator.
1030
+ if (part.type === "tool" || part.type === "text") {
1031
+ inst.lastToolOrTextAt = Date.now();
1032
+ // The agent has shown concrete progress after one or more
1033
+ // interventions — reset the intervention counter so the next
1034
+ // thinking loop has a fresh budget. Clear the intervention
1035
+ // metadata so a later status check does not show stale info.
1036
+ if ((inst.interventionCount ?? 0) > 0) {
1037
+ inst.interventionCount = 0;
1038
+ delete inst.interventionAt;
1039
+ delete inst.interventionReason;
1040
+ }
1041
+ }
1042
+
1043
+ // --- Tool-call cap (spec §6.2) ---
1044
+ if (part.type === "tool") {
1045
+ const nextCount = inst.toolCallCount + 1;
1046
+ const patch: Partial<BackgroundState> = { toolCallCount: nextCount };
1047
+ if (nextCount >= this.toolCallCap) {
1048
+ // Abort and mark failed. Use a fire-and-forget abort because we
1049
+ // do not want to block the handler on a network call.
1050
+ // v0.8.0 — bg-only mode has no HTTP client; the abort is a
1051
+ // no-op there (the runner owns the subprocess).
1052
+ if (this.http !== null) {
1053
+ this.http
1054
+ .abortSession(inst.sessionId, this.worktree)
1055
+ .catch(() => undefined);
1056
+ }
1057
+ patch.status = "failed";
1058
+ patch.error = `Tool-call cap reached (${nextCount}). Aborted to prevent cost runaway.`;
1059
+ patch.completedAt = Date.now();
1060
+ }
1061
+ await this.update(instanceId, patch);
1062
+ // v0.5.5 — persistent auto-restart on tool-call cap
1063
+ if (patch.status === "failed") {
1064
+ await this._maybeAutoRestart(instanceId);
1065
+ return;
1066
+ }
1067
+ }
1068
+
1069
+ // --- Loop-guard threshold-12 detection (spec §4.1) ---
1070
+ if (part.type === "tool" && !inst.loopGuardTool) {
1071
+ const errorText = readToolError(part);
1072
+ if (errorText) {
1073
+ const m = errorText.match(LOOP_GUARD_RE);
1074
+ if (m && m[1]) {
1075
+ const tool = m[1];
1076
+ await this.update(instanceId, {
1077
+ status: "failed",
1078
+ error: `Loop protection: 12 identical calls to ${tool}`,
1079
+ loopGuardTool: tool,
1080
+ completedAt: Date.now(),
1081
+ });
1082
+ // v0.5.5 — persistent auto-restart on loop-guard
1083
+ await this._maybeAutoRestart(instanceId);
1084
+ return;
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ // --- Text-part result preview refresh (spec §3.2) ---
1090
+ if (part.type === "text" && typeof part.text === "string") {
1091
+ const preview = part.text.slice(-RESULT_PREVIEW_MAX);
1092
+ const newIds = [...(inst.resultMessageIds ?? []), ev.messageID];
1093
+ // Deduplicate messageIDs.
1094
+ const seen = new Set<string>();
1095
+ const uniq = newIds.filter((id) => {
1096
+ if (seen.has(id)) return false;
1097
+ seen.add(id);
1098
+ return true;
1099
+ });
1100
+ await this.update(instanceId, {
1101
+ resultPreview: preview,
1102
+ resultMessageIds: uniq,
1103
+ });
1104
+ }
1105
+ }
1106
+
1107
+ /**
1108
+ * Build the result text for `collect` per spec §4.4:
1109
+ * - Fetch assistant messages via `GET /session/{id}/message`.
1110
+ * - Concatenate `TextPart.text` in order; skip everything else.
1111
+ * - If `loopGuardTool` is set, prepend the marker.
1112
+ */
1113
+ private async buildResultText(inst: BackgroundState): Promise<string> {
1114
+ // v0.8.0 — bg-only mode has no HTTP client; fall back to whatever
1115
+ // text-part preview we've already accumulated.
1116
+ if (!this.http) return inst.resultPreview ?? "";
1117
+ const res = await this.http.listMessages(inst.sessionId, this.worktree);
1118
+ if (!res.ok) {
1119
+ this.logger.warn(`bizar: collect: listMessages failed: ${res.error}`);
1120
+ return inst.resultPreview ?? "";
1121
+ }
1122
+ const textParts: string[] = [];
1123
+ for (const msg of res.value) {
1124
+ if (msg.role !== "assistant") continue;
1125
+ for (const p of msg.parts) {
1126
+ if (p.type !== "text") continue;
1127
+ if (typeof p.text === "string" && p.text.length > 0) {
1128
+ textParts.push(p.text);
1129
+ }
1130
+ }
1131
+ }
1132
+ const body = textParts.join("");
1133
+ if (inst.loopGuardTool) {
1134
+ return `[loop guard: 12 identical calls to ${inst.loopGuardTool}]\n${body}`;
1135
+ }
1136
+ return body;
1137
+ }
1138
+ }
1139
+
1140
+ // --- Helpers --------------------------------------------------------------
1141
+
1142
+ /**
1143
+ * Format a millisecond duration as `Xm Ys` (or just `Ys` if under a minute).
1144
+ * Used in stall and thinking-loop error messages.
1145
+ */
1146
+ function formatDuration(ms: number): string {
1147
+ const safeMs = Math.max(0, Math.floor(ms));
1148
+ const minutes = Math.floor(safeMs / 60_000);
1149
+ const seconds = Math.floor((safeMs % 60_000) / 1000);
1150
+ return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
1151
+ }
1152
+
1153
+ /**
1154
+ * Generate a unique instance id: `bgr_<22-char base32>` (ULID-like).
1155
+ * We use 16 random bytes encoded as 22 base32 characters. The prefix
1156
+ * `bgr_` makes the file naming scheme obvious.
1157
+ */
1158
+ export function generateInstanceId(): string {
1159
+ const bytes = new Uint8Array(16);
1160
+ globalThis.crypto.getRandomValues(bytes);
1161
+ return `bgr_${base32(bytes)}`;
1162
+ }
1163
+
1164
+ /**
1165
+ * Crockford base32 (no I, L, O, U) encoder for 16 bytes → 26 chars.
1166
+ * We use 16 bytes (128 bits) to give plenty of entropy; only the first
1167
+ * 22 chars are used for the actual id and the last 4 are dropped.
1168
+ */
1169
+ function base32(bytes: Uint8Array): string {
1170
+ const ALPH = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1171
+ // Encode 5 bytes → 8 chars; pad the last group with zeros.
1172
+ let bits = 0;
1173
+ let value = 0;
1174
+ let out = "";
1175
+ for (let i = 0; i < bytes.length; i++) {
1176
+ value = (value << 8) | (bytes[i] ?? 0);
1177
+ bits += 8;
1178
+ while (bits >= 5) {
1179
+ out += ALPH[(value >>> (bits - 5)) & 0x1f];
1180
+ bits -= 5;
1181
+ }
1182
+ }
1183
+ if (bits > 0) out += ALPH[(value << (5 - bits)) & 0x1f];
1184
+ return out.slice(0, 22);
1185
+ }
1186
+
1187
+ /**
1188
+ * Generate a unique message id: `msg_<22-char base32>`. Same encoding
1189
+ * as `generateInstanceId`. Used for `POST /session/{id}/prompt_async`.
1190
+ */
1191
+ export function generateMessageId(): string {
1192
+ const bytes = new Uint8Array(16);
1193
+ globalThis.crypto.getRandomValues(bytes);
1194
+ return `msg_${base32(bytes)}`;
1195
+ }
1196
+
1197
+ function toView(inst: BackgroundState): InstanceView {
1198
+ const v: InstanceView = {
1199
+ instanceId: inst.instanceId,
1200
+ agent: inst.agent,
1201
+ status: inst.status,
1202
+ startedAt: inst.startedAt,
1203
+ toolCallCount: inst.toolCallCount,
1204
+ promptPreview: inst.promptPreview,
1205
+ parentAgent: inst.parentAgent,
1206
+ sessionId: inst.sessionId,
1207
+ // v0.3.0 — stall and thinking-loop protection. Always include
1208
+ // lastEventAt so a status caller can see how fresh the activity is.
1209
+ lastEventAt: inst.lastEventAt,
1210
+ };
1211
+ if (inst.completedAt !== undefined) v.completedAt = inst.completedAt;
1212
+ if (inst.resultPreview !== undefined) v.resultPreview = inst.resultPreview;
1213
+ if (inst.error !== undefined) v.error = inst.error;
1214
+ if (inst.parentInstanceId !== undefined) v.parentInstanceId = inst.parentInstanceId;
1215
+ // Only surface intervention metadata when we have actually intervened.
1216
+ // `interventionCount > 0` is the canonical signal; absent fields mean
1217
+ // "no intervention has been sent yet", which is the common case.
1218
+ const interventionCount = inst.interventionCount ?? 0;
1219
+ if (interventionCount > 0) {
1220
+ v.interventionCount = interventionCount;
1221
+ if (inst.interventionAt !== undefined) v.interventionAt = inst.interventionAt;
1222
+ if (inst.interventionReason !== undefined) v.interventionReason = inst.interventionReason;
1223
+ }
1224
+ return v;
1225
+ }
1226
+
1227
+ /**
1228
+ * Extract the canonical loop-guard error string from a tool part. The
1229
+ * part may carry the error either on `part.error` or on
1230
+ * `part.state.error` (per spec §4.1).
1231
+ */
1232
+ function readToolError(part: { error?: string; state?: { error?: string } }): string | null {
1233
+ if (typeof part.error === "string" && part.error.length > 0) return part.error;
1234
+ if (part.state && typeof part.state.error === "string" && part.state.error.length > 0) {
1235
+ return part.state.error;
1236
+ }
1237
+ return null;
1238
+ }
1239
+
1240
+ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
1241
+ let timer: ReturnType<typeof setTimeout> | null = null;
1242
+ const timeout = new Promise<never>((_, reject) => {
1243
+ timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
1244
+ });
1245
+ try {
1246
+ return await Promise.race([promise, timeout]);
1247
+ } finally {
1248
+ if (timer !== null) clearTimeout(timer);
1249
+ }
1250
+ }