@polderlabs/bizar 3.23.0 → 4.0.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 (346) hide show
  1. package/README.md +72 -2
  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 +249 -0
  60. package/bizar-dash/src/server/memory-schema.mjs +145 -0
  61. package/bizar-dash/src/server/memory-secrets.mjs +84 -0
  62. package/bizar-dash/src/server/memory-store.mjs +424 -0
  63. package/bizar-dash/src/server/mod-security.mjs +398 -0
  64. package/bizar-dash/src/server/mods-loader.mjs +1067 -0
  65. package/bizar-dash/src/server/notifications-store.mjs +243 -0
  66. package/bizar-dash/src/server/obsidian-store.mjs +318 -0
  67. package/bizar-dash/src/server/opencode-runner.mjs +279 -0
  68. package/bizar-dash/src/server/pair-store.mjs +138 -0
  69. package/bizar-dash/src/server/projects-store.mjs +364 -0
  70. package/bizar-dash/src/server/providers-store.mjs +789 -0
  71. package/bizar-dash/src/server/routes/_shared.mjs +415 -0
  72. package/bizar-dash/src/server/routes/activity.mjs +110 -0
  73. package/bizar-dash/src/server/routes/agents.mjs +134 -0
  74. package/bizar-dash/src/server/routes/artifacts.mjs +279 -0
  75. package/bizar-dash/src/server/routes/auth.mjs +69 -0
  76. package/bizar-dash/src/server/routes/background.mjs +165 -0
  77. package/bizar-dash/src/server/routes/chat.mjs +485 -0
  78. package/bizar-dash/src/server/routes/config.mjs +129 -0
  79. package/bizar-dash/src/server/routes/diagnostics.mjs +50 -0
  80. package/bizar-dash/src/server/routes/dialogs.mjs +86 -0
  81. package/bizar-dash/src/server/routes/fs.mjs +429 -0
  82. package/bizar-dash/src/server/routes/history.mjs +78 -0
  83. package/bizar-dash/src/server/routes/memory.mjs +499 -0
  84. package/bizar-dash/src/server/routes/misc.mjs +89 -0
  85. package/bizar-dash/src/server/routes/mods.mjs +359 -0
  86. package/bizar-dash/src/server/routes/notifications.mjs +54 -0
  87. package/bizar-dash/src/server/routes/obsidian.mjs +239 -0
  88. package/bizar-dash/src/server/routes/opencode-sessions.mjs +55 -0
  89. package/bizar-dash/src/server/routes/overview.mjs +91 -0
  90. package/bizar-dash/src/server/routes/pair.mjs +67 -0
  91. package/bizar-dash/src/server/routes/projects.mjs +175 -0
  92. package/bizar-dash/src/server/routes/providers.mjs +47 -0
  93. package/bizar-dash/src/server/routes/schedules.mjs +104 -0
  94. package/bizar-dash/src/server/routes/settings.mjs +51 -0
  95. package/bizar-dash/src/server/routes/skills.mjs +76 -0
  96. package/bizar-dash/src/server/routes/tasks.mjs +468 -0
  97. package/bizar-dash/src/server/routes/themes.mjs +51 -0
  98. package/bizar-dash/src/server/routes-v2/auth.mjs +59 -0
  99. package/bizar-dash/src/server/routes-v2/events.mjs +107 -0
  100. package/bizar-dash/src/server/routes-v2/health.mjs +21 -0
  101. package/bizar-dash/src/server/routes-v2/index.mjs +90 -0
  102. package/bizar-dash/src/server/routes-v2/sessions.mjs +111 -0
  103. package/bizar-dash/src/server/schedules-runner.mjs +369 -0
  104. package/bizar-dash/src/server/schedules-store.mjs +268 -0
  105. package/bizar-dash/src/server/search-store.mjs +220 -0
  106. package/bizar-dash/src/server/serve-info.mjs +684 -0
  107. package/bizar-dash/src/server/server.mjs +796 -0
  108. package/bizar-dash/src/server/settings-store.mjs +45 -0
  109. package/bizar-dash/src/server/skills-store.mjs +300 -0
  110. package/bizar-dash/src/server/state.mjs +437 -0
  111. package/bizar-dash/src/server/tailscale-store.mjs +113 -0
  112. package/bizar-dash/src/server/task-delegator.mjs +824 -0
  113. package/bizar-dash/src/server/tasks-store.mjs +508 -0
  114. package/bizar-dash/src/server/tui.mjs +923 -0
  115. package/bizar-dash/src/server/update-store.mjs +168 -0
  116. package/bizar-dash/src/server/v2-auth-file.mjs +99 -0
  117. package/bizar-dash/src/server/v2-event-bus.mjs +128 -0
  118. package/bizar-dash/src/server/watcher.mjs +81 -0
  119. package/bizar-dash/src/server/yaml.mjs +238 -0
  120. package/bizar-dash/src/web/App.tsx +718 -0
  121. package/bizar-dash/src/web/MobileApp.tsx +408 -0
  122. package/bizar-dash/src/web/components/ArtifactCreateDialog.tsx +95 -0
  123. package/bizar-dash/src/web/components/ArtifactListDialog.tsx +55 -0
  124. package/bizar-dash/src/web/components/ArtifactViewer.tsx +143 -0
  125. package/bizar-dash/src/web/components/AuditDialog.tsx +79 -0
  126. package/bizar-dash/src/web/components/BgStatusBadge.tsx +32 -0
  127. package/bizar-dash/src/web/components/Button.tsx +56 -0
  128. package/bizar-dash/src/web/components/CanvasContextMenu.tsx +68 -0
  129. package/bizar-dash/src/web/components/Card.tsx +40 -0
  130. package/bizar-dash/src/web/components/CollapsibleSection.tsx +72 -0
  131. package/bizar-dash/src/web/components/CommandDialog.tsx +66 -0
  132. package/bizar-dash/src/web/components/EmptyState.tsx +30 -0
  133. package/bizar-dash/src/web/components/FileBrowser.tsx +633 -0
  134. package/bizar-dash/src/web/components/HelpDialog.tsx +55 -0
  135. package/bizar-dash/src/web/components/KillConfirmDialog.tsx +88 -0
  136. package/bizar-dash/src/web/components/Modal.tsx +215 -0
  137. package/bizar-dash/src/web/components/Notifications.tsx +241 -0
  138. package/bizar-dash/src/web/components/SearchModal.tsx +224 -0
  139. package/bizar-dash/src/web/components/Sidebar.tsx +66 -0
  140. package/bizar-dash/src/web/components/Spinner.tsx +19 -0
  141. package/bizar-dash/src/web/components/StatusBadge.tsx +25 -0
  142. package/bizar-dash/src/web/components/Tag.tsx +28 -0
  143. package/bizar-dash/src/web/components/Toast.tsx +150 -0
  144. package/bizar-dash/src/web/components/Topbar.tsx +368 -0
  145. package/bizar-dash/src/web/components/VisualPlanDialog.tsx +58 -0
  146. package/bizar-dash/src/web/components/chat/AgentChip.tsx +68 -0
  147. package/bizar-dash/src/web/components/chat/ChatBubble.tsx +60 -0
  148. package/bizar-dash/src/web/components/chat/ChatThread.tsx +88 -0
  149. package/bizar-dash/src/web/components/chat/ChatTopBar.tsx +50 -0
  150. package/bizar-dash/src/web/components/chat/Composer.tsx +164 -0
  151. package/bizar-dash/src/web/components/chat/ConfirmModal.tsx +54 -0
  152. package/bizar-dash/src/web/components/chat/EmptyState.tsx +21 -0
  153. package/bizar-dash/src/web/components/chat/FirstRunGreeting.tsx +18 -0
  154. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +32 -0
  155. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +119 -0
  156. package/bizar-dash/src/web/components/chat/LoadingSkeleton.tsx +19 -0
  157. package/bizar-dash/src/web/components/chat/MessageBubble.tsx +60 -0
  158. package/bizar-dash/src/web/components/chat/SessionList.tsx +153 -0
  159. package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +9 -0
  160. package/bizar-dash/src/web/components/chat/SuggestionCards.tsx +55 -0
  161. package/bizar-dash/src/web/components/chat/WelcomeScreen.tsx +36 -0
  162. package/bizar-dash/src/web/components/chat/index.ts +20 -0
  163. package/bizar-dash/src/web/components/chat/useAutoGrowTextarea.ts +18 -0
  164. package/bizar-dash/src/web/components/chat/useChat.ts +219 -0
  165. package/bizar-dash/src/web/components/chat/useSlashCommands.ts +47 -0
  166. package/bizar-dash/src/web/index.html +37 -0
  167. package/bizar-dash/src/web/lib/api.ts +193 -0
  168. package/bizar-dash/src/web/lib/markdown.tsx +59 -0
  169. package/bizar-dash/src/web/lib/types.ts +668 -0
  170. package/bizar-dash/src/web/lib/utils.ts +114 -0
  171. package/bizar-dash/src/web/lib/ws.ts +176 -0
  172. package/bizar-dash/src/web/main.tsx +34 -0
  173. package/bizar-dash/src/web/mobile/MobileApp.tsx +278 -0
  174. package/bizar-dash/src/web/mobile/MobileBottomNav.tsx +38 -0
  175. package/bizar-dash/src/web/mobile/MobileTopbar.tsx +115 -0
  176. package/bizar-dash/src/web/mobile/components/MobileBottomSheet.tsx +182 -0
  177. package/bizar-dash/src/web/mobile/components/MobileListItem.tsx +64 -0
  178. package/bizar-dash/src/web/mobile/components/MobileModal.tsx +123 -0
  179. package/bizar-dash/src/web/mobile/views/MobileActivity.tsx +273 -0
  180. package/bizar-dash/src/web/mobile/views/MobileAgents.tsx +374 -0
  181. package/bizar-dash/src/web/mobile/views/MobileArtifactCanvas.tsx +439 -0
  182. package/bizar-dash/src/web/mobile/views/MobileArtifacts.tsx +143 -0
  183. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +180 -0
  184. package/bizar-dash/src/web/mobile/views/MobileConfig.tsx +175 -0
  185. package/bizar-dash/src/web/mobile/views/MobileHistory.tsx +191 -0
  186. package/bizar-dash/src/web/mobile/views/MobileMods.tsx +149 -0
  187. package/bizar-dash/src/web/mobile/views/MobileMore.tsx +121 -0
  188. package/bizar-dash/src/web/mobile/views/MobileNotifications.tsx +137 -0
  189. package/bizar-dash/src/web/mobile/views/MobilePlans.tsx +26 -0
  190. package/bizar-dash/src/web/mobile/views/MobileSchedules.tsx +685 -0
  191. package/bizar-dash/src/web/mobile/views/MobileSearchModal.tsx +172 -0
  192. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +388 -0
  193. package/bizar-dash/src/web/mobile/views/MobileSkills.tsx +153 -0
  194. package/bizar-dash/src/web/mobile/views/MobileTasks.tsx +564 -0
  195. package/bizar-dash/src/web/mobile.html +20 -0
  196. package/bizar-dash/src/web/mobile.tsx +13 -0
  197. package/bizar-dash/src/web/styles/chat.css +198 -0
  198. package/bizar-dash/src/web/styles/glyphs.css +960 -0
  199. package/bizar-dash/src/web/styles/main.css +8855 -0
  200. package/bizar-dash/src/web/styles/mobile-chat.css +5 -0
  201. package/bizar-dash/src/web/styles/mobile.css +1960 -0
  202. package/bizar-dash/src/web/views/Activity.tsx +1203 -0
  203. package/bizar-dash/src/web/views/Agents.tsx +701 -0
  204. package/bizar-dash/src/web/views/Artifacts.tsx +1480 -0
  205. package/bizar-dash/src/web/views/BackgroundAgents.tsx +450 -0
  206. package/bizar-dash/src/web/views/Chat.tsx +190 -0
  207. package/bizar-dash/src/web/views/Config.tsx +1603 -0
  208. package/bizar-dash/src/web/views/History.tsx +335 -0
  209. package/bizar-dash/src/web/views/ModView.tsx +207 -0
  210. package/bizar-dash/src/web/views/Mods.tsx +693 -0
  211. package/bizar-dash/src/web/views/Overview.tsx +812 -0
  212. package/bizar-dash/src/web/views/Providers.tsx +302 -0
  213. package/bizar-dash/src/web/views/Schedules.tsx +802 -0
  214. package/bizar-dash/src/web/views/Settings.tsx +1787 -0
  215. package/bizar-dash/src/web/views/Skills.tsx +431 -0
  216. package/bizar-dash/src/web/views/Tasks.tsx +1523 -0
  217. package/bizar-dash/src/web/views/glyphs/GlyphRenderer.tsx +613 -0
  218. package/bizar-dash/src/web/views/glyphs/components.tsx +1098 -0
  219. package/bizar-dash/templates/mod/FORMAT.md +76 -0
  220. package/bizar-dash/templates/mod/hello-mod/README.md +19 -0
  221. package/bizar-dash/templates/mod/hello-mod/agents/greeter.md +8 -0
  222. package/bizar-dash/templates/mod/hello-mod/commands/hello.md +6 -0
  223. package/bizar-dash/templates/mod/hello-mod/mod.json +20 -0
  224. package/bizar-dash/templates/mod/hello-mod/routes/ping.mjs +9 -0
  225. package/bizar-dash/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  226. package/bizar-dash/tests/dashboard-ports.test.mjs +138 -0
  227. package/bizar-dash/tests/graphify-mod-spawn.node.test.mjs +90 -0
  228. package/bizar-dash/tests/memory-git.test.mjs +138 -0
  229. package/bizar-dash/tests/memory-schema.test.mjs +198 -0
  230. package/bizar-dash/tests/memory-secrets.test.mjs +121 -0
  231. package/bizar-dash/tests/memory-store.test.mjs +390 -0
  232. package/bizar-dash/tests/memory-sync.test.mjs +141 -0
  233. package/bizar-dash/tests/mod-instructions.node.test.mjs +188 -0
  234. package/bizar-dash/tests/mod-security.test.mjs +273 -0
  235. package/bizar-dash/tests/mod-upgrade.node.test.mjs +357 -0
  236. package/bizar-dash/tests/no-agent-browser.node.test.mjs +98 -0
  237. package/bizar-dash/tests/obsidian-back-compat.test.mjs +199 -0
  238. package/bizar-dash/tests/opencode-runner.test.mjs +172 -0
  239. package/bizar-dash/tests/path-safe.test.mjs +108 -0
  240. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +333 -0
  241. package/bizar-dash/tests/smoke-v2.mjs +212 -0
  242. package/bizar-dash/tests/strip-thinking.test.mjs +175 -0
  243. package/bizar-dash/tests/submit-feedback.test.mjs +353 -0
  244. package/bizar-dash/tests/tmux-wrap.test.mjs +145 -0
  245. package/bizar-dash/tests/yaml.test.mjs +128 -0
  246. package/bizar-dash/tsconfig.json +23 -0
  247. package/bizar-dash/vite.config.ts +27 -0
  248. package/cli/atomic.mjs +73 -0
  249. package/cli/bin.mjs +43 -46
  250. package/cli/copy.mjs +3 -0
  251. package/cli/dev-link.mjs +17 -1
  252. package/cli/init.mjs +135 -1
  253. package/cli/install.mjs +10 -23
  254. package/cli/memory-constants.mjs +59 -0
  255. package/cli/memory.mjs +757 -0
  256. package/cli/service.mjs +5 -6
  257. package/config/skills/obsidian/SKILL.md +213 -65
  258. package/install.sh +48 -18
  259. package/package.json +49 -14
  260. package/packages/sdk/LICENSE +21 -0
  261. package/packages/sdk/README.md +80 -0
  262. package/packages/sdk/src/client.ts +183 -0
  263. package/packages/sdk/src/errors.ts +120 -0
  264. package/packages/sdk/src/events.ts +153 -0
  265. package/packages/sdk/src/index.ts +63 -0
  266. package/packages/sdk/src/types.ts +176 -0
  267. package/packages/sdk/src/version.ts +4 -0
  268. package/packages/sdk/tests/client.test.ts +217 -0
  269. package/packages/sdk/tests/errors.test.ts +108 -0
  270. package/packages/sdk/tests/events.test.ts +139 -0
  271. package/packages/sdk/tests/fixtures/fetch-mock.ts +152 -0
  272. package/packages/sdk/tests/fixtures/sse-mock.ts +30 -0
  273. package/packages/sdk/tsconfig.json +25 -0
  274. package/packages/sdk/vitest.config.ts +9 -0
  275. package/plugins/bizar/LICENSE +21 -0
  276. package/plugins/bizar/README.md +448 -0
  277. package/plugins/bizar/dist/index.js +29901 -0
  278. package/plugins/bizar/index.ts +1484 -0
  279. package/plugins/bizar/scripts/check-forbidden-imports.sh +33 -0
  280. package/plugins/bizar/src/background-state.ts +551 -0
  281. package/plugins/bizar/src/background.ts +1250 -0
  282. package/plugins/bizar/src/commands-impl.ts +464 -0
  283. package/plugins/bizar/src/commands.ts +1127 -0
  284. package/plugins/bizar/src/dashboard-client.ts +233 -0
  285. package/plugins/bizar/src/event-stream.ts +606 -0
  286. package/plugins/bizar/src/fingerprint.ts +120 -0
  287. package/plugins/bizar/src/handoff.ts +79 -0
  288. package/plugins/bizar/src/http-client.ts +467 -0
  289. package/plugins/bizar/src/key-rotation.ts +218 -0
  290. package/plugins/bizar/src/logger.ts +144 -0
  291. package/plugins/bizar/src/loop.ts +176 -0
  292. package/plugins/bizar/src/opencode-runner.ts +390 -0
  293. package/plugins/bizar/src/options.ts +421 -0
  294. package/plugins/bizar/src/plan-fs.ts +323 -0
  295. package/plugins/bizar/src/reasoning-clean.ts +454 -0
  296. package/plugins/bizar/src/report.ts +178 -0
  297. package/plugins/bizar/src/research-prompt.ts +35 -0
  298. package/plugins/bizar/src/serve-info.ts +228 -0
  299. package/plugins/bizar/src/serve.ts +496 -0
  300. package/plugins/bizar/src/settings.ts +349 -0
  301. package/plugins/bizar/src/state.ts +298 -0
  302. package/plugins/bizar/src/tools/bg-collect.ts +104 -0
  303. package/plugins/bizar/src/tools/bg-get-comments.ts +239 -0
  304. package/plugins/bizar/src/tools/bg-kill.ts +87 -0
  305. package/plugins/bizar/src/tools/bg-spawn.ts +401 -0
  306. package/plugins/bizar/src/tools/bg-status.ts +99 -0
  307. package/plugins/bizar/src/tools/plan-action.ts +767 -0
  308. package/plugins/bizar/src/tools/read-glyph-feedback.ts +170 -0
  309. package/plugins/bizar/src/tools/wait-for-feedback.ts +402 -0
  310. package/plugins/bizar/tests/attach-handler-bug.test.ts +169 -0
  311. package/plugins/bizar/tests/background-state.test.ts +277 -0
  312. package/plugins/bizar/tests/background.test.ts +402 -0
  313. package/plugins/bizar/tests/block.test.ts +195 -0
  314. package/plugins/bizar/tests/canonical-key-order.test.ts +75 -0
  315. package/plugins/bizar/tests/commands-impl.test.ts +442 -0
  316. package/plugins/bizar/tests/commands.test.ts +548 -0
  317. package/plugins/bizar/tests/config.test.ts +122 -0
  318. package/plugins/bizar/tests/dashboard-client.test.ts +159 -0
  319. package/plugins/bizar/tests/dispose.test.ts +336 -0
  320. package/plugins/bizar/tests/event-stream.test.ts +409 -0
  321. package/plugins/bizar/tests/event.test.ts +262 -0
  322. package/plugins/bizar/tests/fingerprint.test.ts +162 -0
  323. package/plugins/bizar/tests/http-client.test.ts +404 -0
  324. package/plugins/bizar/tests/init-helpers.test.ts +203 -0
  325. package/plugins/bizar/tests/integration/slash-command.test.ts +348 -0
  326. package/plugins/bizar/tests/integration/tool-routing.test.ts +314 -0
  327. package/plugins/bizar/tests/key-rotation.test.ts +396 -0
  328. package/plugins/bizar/tests/loop.test.ts +397 -0
  329. package/plugins/bizar/tests/options.test.ts +276 -0
  330. package/plugins/bizar/tests/reasoning-clean.test.ts +422 -0
  331. package/plugins/bizar/tests/serve.test.ts +339 -0
  332. package/plugins/bizar/tests/settings.test.ts +351 -0
  333. package/plugins/bizar/tests/stall-think.test.ts +750 -0
  334. package/plugins/bizar/tests/state.test.ts +276 -0
  335. package/plugins/bizar/tests/tools/bg-collect.test.ts +337 -0
  336. package/plugins/bizar/tests/tools/bg-get-comments.test.ts +485 -0
  337. package/plugins/bizar/tests/tools/bg-kill.test.ts +235 -0
  338. package/plugins/bizar/tests/tools/bg-spawn-delegation.test.ts +147 -0
  339. package/plugins/bizar/tests/tools/bg-spawn.test.ts +311 -0
  340. package/plugins/bizar/tests/tools/bg-status.test.ts +217 -0
  341. package/plugins/bizar/tests/tools/opencode-runner.test.ts +115 -0
  342. package/plugins/bizar/tests/tools/plan-action.test.ts +599 -0
  343. package/plugins/bizar/tests/tools/read-glyph-feedback.test.ts +253 -0
  344. package/plugins/bizar/tests/tools/wait-for-feedback.test.ts +390 -0
  345. package/plugins/bizar/tests/update-deadlock.test.ts +145 -0
  346. package/plugins/bizar/tsconfig.json +29 -0
@@ -0,0 +1,802 @@
1
+ // src/views/Schedules.tsx — list, create, edit, run, delete schedules for the active project.
2
+ //
3
+ // v3.9.0 — overhauled with structured DOW/time/TZ pickers, a reusable
4
+ // ScheduleEditorModal that handles both create and edit, a human-readable
5
+ // schedule label on every card, and a timezone-aware next-run display.
6
+ import { useEffect, useMemo, useState } from 'react';
7
+ import {
8
+ Clock,
9
+ Plus,
10
+ PlayCircle,
11
+ Pencil,
12
+ Trash2,
13
+ RefreshCw,
14
+ History,
15
+ ChevronDown,
16
+ } from 'lucide-react';
17
+ import { Card, CardTitle, CardMeta } from '../components/Card';
18
+ import { Button } from '../components/Button';
19
+ import { EmptyState } from '../components/EmptyState';
20
+ import { Spinner } from '../components/Spinner';
21
+ import { useToast } from '../components/Toast';
22
+ import { useModal } from '../components/Modal';
23
+ import { api } from '../lib/api';
24
+ import { formatRelative } from '../lib/utils';
25
+ import type { Schedule, Settings, Snapshot } from '../lib/types';
26
+
27
+ type Props = {
28
+ snapshot: Snapshot;
29
+ settings: Settings;
30
+ activeTab: string;
31
+ setActiveTab: (id: string) => void;
32
+ refreshSnapshot: () => Promise<void>;
33
+ };
34
+
35
+ const TYPES = ['interval', 'cron', 'once'] as const;
36
+ const ACTIONS = ['command', 'agent', 'webhook'] as const;
37
+
38
+ // v3.9.0 — Curated IANA timezone list. "Other…" lets the operator type
39
+ // any zone croner accepts (the backend passes the string through).
40
+ const TIMEZONES: Array<{ value: string; label: string }> = [
41
+ { value: 'UTC', label: 'UTC' },
42
+ { value: 'America/New_York', label: 'America/New_York (ET)' },
43
+ { value: 'America/Los_Angeles', label: 'America/Los_Angeles (PT)' },
44
+ { value: 'America/Chicago', label: 'America/Chicago (CT)' },
45
+ { value: 'Europe/London', label: 'Europe/London (UK)' },
46
+ { value: 'Europe/Berlin', label: 'Europe/Berlin (CET)' },
47
+ { value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' },
48
+ ];
49
+
50
+ const MINUTE_OPTIONS = Array.from({ length: 12 }, (_, i) => i * 5); // 0,5,10,…55
51
+ const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
52
+ value: h,
53
+ label: h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`,
54
+ }));
55
+ const DOW_OPTIONS: Array<{ value: string; label: string }> = [
56
+ { value: '*', label: 'Every day' },
57
+ { value: '0', label: 'Sunday' },
58
+ { value: '1', label: 'Monday' },
59
+ { value: '2', label: 'Tuesday' },
60
+ { value: '3', label: 'Wednesday' },
61
+ { value: '4', label: 'Thursday' },
62
+ { value: '5', label: 'Friday' },
63
+ { value: '6', label: 'Saturday' },
64
+ ];
65
+
66
+ const INTERVAL_UNITS: Array<{ value: string; label: string }> = [
67
+ { value: 's', label: 'seconds' },
68
+ { value: 'm', label: 'minutes' },
69
+ { value: 'h', label: 'hours' },
70
+ { value: 'd', label: 'days' },
71
+ ];
72
+
73
+ /** Best-effort cron parser — used to pre-populate the structured form
74
+ * when editing a schedule the user originally saved with raw cron text. */
75
+ function parseCronFields(expr: string): { minute: number; hour: number; dow: string } | null {
76
+ if (typeof expr !== 'string') return null;
77
+ const parts = expr.trim().split(/\s+/);
78
+ if (parts.length !== 5) return null;
79
+ const [m, h, , , dow] = parts;
80
+ // We only pre-populate when every field is a literal — wildcards and
81
+ // ranges (other than `*`) leave the form blank rather than guess.
82
+ const minNum = parseInt(m, 10);
83
+ const hourNum = parseInt(h, 10);
84
+ if (!Number.isFinite(minNum) || !Number.isFinite(hourNum)) return null;
85
+ if (m !== String(minNum) || h !== String(hourNum)) return null;
86
+ const dowClean = dow === '*' ? '*' : parseInt(dow, 10);
87
+ if (dowClean === null || (dow !== '*' && !Number.isFinite(dowClean))) return null;
88
+ return { minute: minNum, hour: hourNum, dow: dow === '*' ? '*' : String(dowClean) };
89
+ }
90
+
91
+ function parseIntervalParts(s: string): { n: number; unit: string } | null {
92
+ if (typeof s !== 'string') return null;
93
+ const m = /^(\d+)\s*([smhd])$/i.exec(s.trim());
94
+ if (!m) return null;
95
+ return { n: parseInt(m[1], 10), unit: m[2].toLowerCase() };
96
+ }
97
+
98
+ type EditorState = {
99
+ name: string;
100
+ type: 'cron' | 'interval' | 'once';
101
+ cronMinute: number;
102
+ cronHour: number;
103
+ cronDow: string;
104
+ showAdvanced: boolean;
105
+ rawCron: string;
106
+ intervalN: number;
107
+ intervalUnit: string;
108
+ onceAt: string; // datetime-local string
109
+ timezone: string;
110
+ customTimezone: string;
111
+ actionType: 'command' | 'agent' | 'webhook';
112
+ actionTarget: string;
113
+ actionPrompt: string;
114
+ skipIfBudgetLow: boolean;
115
+ maxConcurrent: number;
116
+ enabled: boolean;
117
+ humanLabel: string;
118
+ };
119
+
120
+ function buildInitialState(initial?: Schedule): EditorState {
121
+ if (!initial) {
122
+ return {
123
+ name: '',
124
+ type: 'cron',
125
+ cronMinute: 0,
126
+ cronHour: 13,
127
+ cronDow: '0',
128
+ showAdvanced: false,
129
+ rawCron: '0 13 * * 0',
130
+ intervalN: 30,
131
+ intervalUnit: 'm',
132
+ onceAt: '',
133
+ timezone: 'UTC',
134
+ customTimezone: '',
135
+ actionType: 'agent',
136
+ actionTarget: '',
137
+ actionPrompt: '',
138
+ skipIfBudgetLow: false,
139
+ maxConcurrent: 6,
140
+ enabled: true,
141
+ humanLabel: '',
142
+ };
143
+ }
144
+ const parsed = initial.type === 'cron' ? parseCronFields(initial.schedule) : null;
145
+ const parsedInterval = initial.type === 'interval' ? parseIntervalParts(initial.schedule) : null;
146
+ const tz = initial.timezone || 'UTC';
147
+ const tzKnown = TIMEZONES.some((t) => t.value === tz);
148
+ return {
149
+ name: initial.name || '',
150
+ type: initial.type,
151
+ cronMinute: parsed?.minute ?? 0,
152
+ cronHour: parsed?.hour ?? 9,
153
+ cronDow: parsed?.dow ?? '*',
154
+ showAdvanced: !parsed,
155
+ rawCron: initial.schedule,
156
+ intervalN: parsedInterval?.n ?? 30,
157
+ intervalUnit: parsedInterval?.unit ?? 'm',
158
+ onceAt: initial.type === 'once' ? toDatetimeLocal(initial.schedule) : '',
159
+ timezone: tzKnown ? tz : 'Other…',
160
+ customTimezone: tzKnown ? '' : tz,
161
+ actionType: initial.action.type,
162
+ actionTarget: initial.action.target || '',
163
+ actionPrompt: initial.action.prompt || '',
164
+ skipIfBudgetLow: !!initial.budgetCheck?.skipIfBudgetLow,
165
+ maxConcurrent: Number.isFinite(initial.budgetCheck?.maxConcurrent)
166
+ ? initial.budgetCheck!.maxConcurrent!
167
+ : 6,
168
+ enabled: initial.enabled !== false,
169
+ humanLabel: '',
170
+ };
171
+ }
172
+
173
+ function toDatetimeLocal(iso: string): string {
174
+ if (!iso) return '';
175
+ try {
176
+ const d = new Date(iso);
177
+ if (Number.isNaN(d.getTime())) return '';
178
+ // datetime-local needs YYYY-MM-DDTHH:mm in *local* time.
179
+ const pad = (n: number) => String(n).padStart(2, '0');
180
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
181
+ } catch {
182
+ return '';
183
+ }
184
+ }
185
+
186
+ /** Compose the cron / interval / once schedule string + a human label. */
187
+ function composeSchedule(s: EditorState): { schedule: string; humanLabel: string } {
188
+ if (s.type === 'cron') {
189
+ if (s.showAdvanced) {
190
+ return { schedule: s.rawCron.trim(), humanLabel: s.humanLabel || s.rawCron.trim() };
191
+ }
192
+ const cron = `${s.cronMinute} ${s.cronHour} * * ${s.cronDow}`;
193
+ const dowName = DOW_OPTIONS.find((d) => d.value === s.cronDow)?.label || '';
194
+ const hourLabel = HOUR_OPTIONS.find((h) => h.value === s.cronHour)?.label || `${s.cronHour}`;
195
+ const minute = String(s.cronMinute).padStart(2, '0');
196
+ const human =
197
+ dowName === 'Every day'
198
+ ? `Every day at ${hourLabel.replace(' ', '')} (min ${minute})`
199
+ : `Every ${dowName} at ${hourLabel.replace(' ', '')} (min ${minute})`;
200
+ return { schedule: cron, humanLabel: s.humanLabel || human };
201
+ }
202
+ if (s.type === 'interval') {
203
+ const unit = INTERVAL_UNITS.find((u) => u.value === s.intervalUnit)?.label || s.intervalUnit;
204
+ const schedule = `${s.intervalN}${s.intervalUnit}`;
205
+ return {
206
+ schedule,
207
+ humanLabel: s.humanLabel || `Every ${s.intervalN} ${unit}`,
208
+ };
209
+ }
210
+ // once
211
+ if (!s.onceAt) return { schedule: '', humanLabel: s.humanLabel || '' };
212
+ const iso = new Date(s.onceAt).toISOString();
213
+ return {
214
+ schedule: iso,
215
+ humanLabel: s.humanLabel || new Date(s.onceAt).toLocaleString(),
216
+ };
217
+ }
218
+
219
+ export function Schedules({ snapshot, refreshSnapshot }: Props) {
220
+ const toast = useToast();
221
+ const modal = useModal();
222
+ const [schedules, setSchedules] = useState<Schedule[]>(snapshot.schedules || []);
223
+ const [loading, setLoading] = useState(!snapshot.schedules);
224
+
225
+ const reload = async () => {
226
+ try {
227
+ const r = await api.get<{ schedules: Schedule[]; projectId: string | null }>(
228
+ '/projects/active/schedules',
229
+ );
230
+ setSchedules(r.schedules || []);
231
+ } catch (err) {
232
+ toast.error(`Schedules load failed: ${(err as Error).message}`);
233
+ } finally {
234
+ setLoading(false);
235
+ }
236
+ };
237
+
238
+ useEffect(() => {
239
+ if (snapshot.schedules?.length || snapshot.schedules) {
240
+ setSchedules(snapshot.schedules || []);
241
+ setLoading(false);
242
+ return;
243
+ }
244
+ reload();
245
+ // eslint-disable-next-line react-hooks/exhaustive-deps
246
+ }, [snapshot.schedules]);
247
+
248
+ const openEditor = (initial?: Schedule) => {
249
+ modal.open({
250
+ title: initial ? `Edit schedule: ${initial.name}` : 'New schedule',
251
+ width: 640,
252
+ children: (
253
+ <ScheduleEditorModal
254
+ initial={initial}
255
+ onClose={modal.close}
256
+ onSubmitted={async (saved) => {
257
+ setSchedules((cur) => {
258
+ const idx = cur.findIndex((s) => s.id === saved.id);
259
+ if (idx === -1) return [...cur, saved];
260
+ const copy = cur.slice();
261
+ copy[idx] = saved;
262
+ return copy;
263
+ });
264
+ toast.success(initial ? 'Schedule updated.' : 'Schedule created.');
265
+ modal.close();
266
+ await refreshSnapshot();
267
+ }}
268
+ onError={(msg) => toast.error(msg)}
269
+ />
270
+ ),
271
+ });
272
+ };
273
+
274
+ const onRun = async (s: Schedule) => {
275
+ try {
276
+ const r = await api.post<{ ok: boolean; schedule: Schedule; runResult: { error?: string; stdout?: string } }>(
277
+ `/schedules/${encodeURIComponent(s.id)}/run`,
278
+ );
279
+ toast.success(r.ok ? 'Schedule ran.' : `Run failed: ${r.runResult?.error || 'unknown'}`);
280
+ await reload();
281
+ } catch (err) {
282
+ toast.error(`Run failed: ${(err as Error).message}`);
283
+ }
284
+ };
285
+
286
+ const onDelete = async (s: Schedule) => {
287
+ if (!confirm(`Delete schedule "${s.name}"?`)) return;
288
+ try {
289
+ await api.del(`/schedules/${encodeURIComponent(s.id)}`);
290
+ setSchedules((cur) => cur.filter((x) => x.id !== s.id));
291
+ toast.success('Schedule deleted.');
292
+ } catch (err) {
293
+ toast.error(`Delete failed: ${(err as Error).message}`);
294
+ }
295
+ };
296
+
297
+ if (loading) {
298
+ return (
299
+ <div className="view-loading"><Spinner size="lg" /></div>
300
+ );
301
+ }
302
+
303
+ return (
304
+ <div className="view view-schedules">
305
+ <header className="view-header">
306
+ <div className="view-header-text">
307
+ <h2 className="view-title">
308
+ <Clock size={18} /> Schedules ({schedules.length})
309
+ </h2>
310
+ <p className="view-subtitle">
311
+ Recurring tasks for the active project: <strong>{snapshot.activeProject?.name || '(none)'}</strong>.
312
+ Service daemon runs them at the right time.
313
+ </p>
314
+ </div>
315
+ <div className="view-actions">
316
+ <Button variant="secondary" size="sm" onClick={reload}>
317
+ <RefreshCw size={14} /> Refresh
318
+ </Button>
319
+ <Button variant="primary" size="sm" onClick={() => openEditor()}>
320
+ <Plus size={14} /> New schedule
321
+ </Button>
322
+ </div>
323
+ </header>
324
+
325
+ {schedules.length === 0 ? (
326
+ <EmptyState
327
+ icon={<Clock size={32} />}
328
+ title="No schedules"
329
+ message={
330
+ snapshot.activeProject
331
+ ? 'Add a schedule to run commands, webhooks, or agent tasks on a cron / interval / one-shot basis.'
332
+ : 'Activate a project first to scope schedules.'
333
+ }
334
+ />
335
+ ) : (
336
+ <div className="schedule-grid">
337
+ {schedules.map((s) => (
338
+ <Card key={s.id} className="schedule-card">
339
+ <div className="schedule-card-head">
340
+ <div>
341
+ <CardTitle>{s.name}</CardTitle>
342
+ <CardMeta>
343
+ <code>{s.type}</code> ·{' '}
344
+ <span title={s.schedule}>{humanizeSchedule(s) || s.schedule}</span>
345
+ {s.timezone && s.timezone !== 'UTC' ? <> · <code>{s.timezone}</code></> : null}
346
+ {' · '}
347
+ <span className={s.enabled ? 'status-on' : 'status-neutral'}>
348
+ {s.enabled ? 'enabled' : 'disabled'}
349
+ </span>
350
+ </CardMeta>
351
+ </div>
352
+ <div className="schedule-card-actions">
353
+ <Button variant="secondary" size="sm" onClick={() => openEditor(s)}>
354
+ <Pencil size={12} /> Edit
355
+ </Button>
356
+ <Button variant="secondary" size="sm" onClick={() => onRun(s)}>
357
+ <PlayCircle size={12} /> Run now
358
+ </Button>
359
+ <button
360
+ type="button"
361
+ className="icon-btn icon-btn-danger"
362
+ aria-label="Delete"
363
+ title="Delete"
364
+ onClick={() => onDelete(s)}
365
+ >
366
+ <Trash2 size={12} />
367
+ </button>
368
+ </div>
369
+ </div>
370
+ <div className="schedule-card-action">
371
+ <span className="muted">action:</span>{' '}
372
+ <code>
373
+ {s.action.type} {s.action.target}
374
+ </code>
375
+ {s.action.type === 'agent' && s.action.prompt && (
376
+ <div className="schedule-card-prompt">
377
+ <span className="muted">prompt:</span> {s.action.prompt}
378
+ </div>
379
+ )}
380
+ </div>
381
+ {s.budgetCheck?.skipIfBudgetLow && (
382
+ <div className="schedule-card-budget">
383
+ <span className="muted">budget gate:</span>{' '}
384
+ skip if concurrent ≥ {s.budgetCheck.maxConcurrent ?? 6}
385
+ </div>
386
+ )}
387
+ <div className="schedule-card-times">
388
+ <div>
389
+ <span className="muted">last</span>{' '}
390
+ {s.lastRun ? formatRelative(s.lastRun) : '—'}
391
+ {s.lastResult && (
392
+ <span className={`tag ${resultTagClass(s.lastResult)}`}>{s.lastResult}</span>
393
+ )}
394
+ {s.lastError && (
395
+ <span className="muted schedule-card-error" title={s.lastError}>
396
+ {' '}— {truncate(s.lastError, 80)}
397
+ </span>
398
+ )}
399
+ </div>
400
+ <div>
401
+ <span className="muted">next</span>{' '}
402
+ {s.nextRun ? (
403
+ <span title={new Date(s.nextRun).toISOString()}>
404
+ {formatNextRun(s.nextRun, s.timezone)}
405
+ </span>
406
+ ) : (
407
+ '—'
408
+ )}
409
+ {s.nextRun && (
410
+ <span className="muted"> · {formatRelative(s.nextRun)}</span>
411
+ )}
412
+ </div>
413
+ </div>
414
+ {s.history && s.history.length > 0 && (
415
+ <details className="schedule-card-history">
416
+ <summary>
417
+ <History size={12} /> {s.history.length} run{s.history.length === 1 ? '' : 's'}
418
+ </summary>
419
+ <ul>
420
+ {s.history.slice(-10).reverse().map((h, i) => (
421
+ <li key={i}>
422
+ <span className="tabular-nums muted">{formatRelative(h.ts)}</span>{' '}
423
+ <span className={`tag ${resultTagClass(h.result)}`}>{h.result}</span>
424
+ {h.error && <span className="muted"> — {truncate(h.error, 80)}</span>}
425
+ </li>
426
+ ))}
427
+ </ul>
428
+ </details>
429
+ )}
430
+ </Card>
431
+ ))}
432
+ </div>
433
+ )}
434
+ </div>
435
+ );
436
+ }
437
+
438
+ /* ------------------------------------------------------------------ */
439
+ /* Editor modal */
440
+ /* ------------------------------------------------------------------ */
441
+
442
+ function ScheduleEditorModal({
443
+ initial,
444
+ onClose,
445
+ onSubmitted,
446
+ onError,
447
+ }: {
448
+ initial?: Schedule;
449
+ onClose: () => void;
450
+ onSubmitted: (saved: Schedule) => void | Promise<void>;
451
+ onError: (msg: string) => void;
452
+ }) {
453
+ const [state, setState] = useState<EditorState>(() => buildInitialState(initial));
454
+ const [submitting, setSubmitting] = useState(false);
455
+ const tzSelectValue = useMemo(() => {
456
+ if (TIMEZONES.some((t) => t.value === state.timezone)) return state.timezone;
457
+ return 'Other…';
458
+ }, [state.timezone]);
459
+
460
+ const update = <K extends keyof EditorState>(key: K, value: EditorState[K]) => {
461
+ setState((cur) => ({ ...cur, [key]: value }));
462
+ };
463
+
464
+ const handleSubmit = async (e: React.FormEvent) => {
465
+ e.preventDefault();
466
+ const trimmedName = state.name.trim();
467
+ if (!trimmedName) {
468
+ onError('Name is required.');
469
+ return;
470
+ }
471
+ const composed = composeSchedule(state);
472
+ if (!composed.schedule) {
473
+ onError('Schedule value is required.');
474
+ return;
475
+ }
476
+ if (!state.actionTarget.trim()) {
477
+ onError('Action target is required.');
478
+ return;
479
+ }
480
+ const tz = state.timezone === 'Other…' ? state.customTimezone.trim() : state.timezone;
481
+ const actionPayload: Record<string, unknown> = {
482
+ type: state.actionType,
483
+ target: state.actionTarget.trim(),
484
+ };
485
+ if (state.actionType === 'agent' && state.actionPrompt.trim()) {
486
+ actionPayload.prompt = state.actionPrompt.trim();
487
+ }
488
+ const payload: Record<string, unknown> = {
489
+ name: trimmedName,
490
+ type: state.type,
491
+ schedule: composed.schedule,
492
+ timezone: tz || 'UTC',
493
+ action: actionPayload,
494
+ budgetCheck: {
495
+ maxConcurrent: state.maxConcurrent,
496
+ skipIfBudgetLow: state.skipIfBudgetLow,
497
+ },
498
+ enabled: state.enabled,
499
+ };
500
+ setSubmitting(true);
501
+ try {
502
+ const saved = initial
503
+ ? await api.put<Schedule>(`/schedules/${encodeURIComponent(initial.id)}`, payload)
504
+ : await api.post<Schedule>('/schedules', payload);
505
+ await onSubmitted(saved);
506
+ } catch (err) {
507
+ onError(`${initial ? 'Update' : 'Create'} failed: ${(err as Error).message}`);
508
+ } finally {
509
+ setSubmitting(false);
510
+ }
511
+ };
512
+
513
+ return (
514
+ <form className="schedule-form" onSubmit={handleSubmit}>
515
+ <label className="field-label">Name</label>
516
+ <input
517
+ className="input"
518
+ type="text"
519
+ placeholder="Weekly code review"
520
+ value={state.name}
521
+ onChange={(e) => update('name', e.target.value)}
522
+ autoFocus
523
+ />
524
+
525
+ <div className="task-form-row">
526
+ <div className="task-form-field">
527
+ <label className="field-label">Type</label>
528
+ <select
529
+ className="select"
530
+ value={state.type}
531
+ onChange={(e) => update('type', e.target.value as EditorState['type'])}
532
+ >
533
+ {TYPES.map((t) => (
534
+ <option key={t} value={t}>{t}</option>
535
+ ))}
536
+ </select>
537
+ </div>
538
+ </div>
539
+
540
+ {state.type === 'cron' && (
541
+ <div className="schedule-cron-group">
542
+ {!state.showAdvanced && (
543
+ <>
544
+ <div className="task-form-row">
545
+ <div className="task-form-field">
546
+ <label className="field-label">Day of week</label>
547
+ <select
548
+ className="select"
549
+ value={state.cronDow}
550
+ onChange={(e) => update('cronDow', e.target.value)}
551
+ >
552
+ {DOW_OPTIONS.map((d) => (
553
+ <option key={d.value} value={d.value}>{d.label}</option>
554
+ ))}
555
+ </select>
556
+ </div>
557
+ <div className="task-form-field">
558
+ <label className="field-label">Hour</label>
559
+ <select
560
+ className="select"
561
+ value={String(state.cronHour)}
562
+ onChange={(e) => update('cronHour', parseInt(e.target.value, 10))}
563
+ >
564
+ {HOUR_OPTIONS.map((h) => (
565
+ <option key={h.value} value={String(h.value)}>{h.label}</option>
566
+ ))}
567
+ </select>
568
+ </div>
569
+ <div className="task-form-field">
570
+ <label className="field-label">Minute</label>
571
+ <select
572
+ className="select"
573
+ value={String(state.cronMinute)}
574
+ onChange={(e) => update('cronMinute', parseInt(e.target.value, 10))}
575
+ >
576
+ {MINUTE_OPTIONS.map((m) => (
577
+ <option key={m} value={String(m)}>{String(m).padStart(2, '0')}</option>
578
+ ))}
579
+ </select>
580
+ </div>
581
+ </div>
582
+ </>
583
+ )}
584
+ {state.showAdvanced && (
585
+ <div className="task-form-field">
586
+ <label className="field-label">Cron expression</label>
587
+ <input
588
+ className="input"
589
+ type="text"
590
+ value={state.rawCron}
591
+ onChange={(e) => update('rawCron', e.target.value)}
592
+ placeholder="0 13 * * 0"
593
+ />
594
+ </div>
595
+ )}
596
+ <button
597
+ type="button"
598
+ className="link-btn"
599
+ onClick={() => update('showAdvanced', !state.showAdvanced)}
600
+ >
601
+ <ChevronDown
602
+ size={12}
603
+ style={{
604
+ transform: state.showAdvanced ? 'rotate(180deg)' : 'none',
605
+ transition: 'transform 0.15s',
606
+ }}
607
+ />
608
+ {state.showAdvanced ? 'Hide advanced' : 'Advanced (raw cron)'}
609
+ </button>
610
+ </div>
611
+ )}
612
+
613
+ {state.type === 'interval' && (
614
+ <div className="task-form-row">
615
+ <div className="task-form-field" style={{ flex: 1 }}>
616
+ <label className="field-label">Every</label>
617
+ <input
618
+ className="input"
619
+ type="number"
620
+ min={1}
621
+ value={state.intervalN}
622
+ onChange={(e) => update('intervalN', parseInt(e.target.value, 10) || 1)}
623
+ />
624
+ </div>
625
+ <div className="task-form-field" style={{ flex: 1 }}>
626
+ <label className="field-label">Unit</label>
627
+ <select
628
+ className="select"
629
+ value={state.intervalUnit}
630
+ onChange={(e) => update('intervalUnit', e.target.value)}
631
+ >
632
+ {INTERVAL_UNITS.map((u) => (
633
+ <option key={u.value} value={u.value}>{u.label}</option>
634
+ ))}
635
+ </select>
636
+ </div>
637
+ </div>
638
+ )}
639
+
640
+ {state.type === 'once' && (
641
+ <div className="task-form-field">
642
+ <label className="field-label">Run at</label>
643
+ <input
644
+ className="input"
645
+ type="datetime-local"
646
+ value={state.onceAt}
647
+ onChange={(e) => update('onceAt', e.target.value)}
648
+ />
649
+ </div>
650
+ )}
651
+
652
+ <div className="task-form-row">
653
+ <div className="task-form-field" style={{ flex: 1 }}>
654
+ <label className="field-label">Timezone</label>
655
+ <select
656
+ className="select"
657
+ value={tzSelectValue}
658
+ onChange={(e) => update('timezone', e.target.value)}
659
+ >
660
+ {TIMEZONES.map((t) => (
661
+ <option key={t.value} value={t.value}>{t.label}</option>
662
+ ))}
663
+ <option value="Other…">Other…</option>
664
+ </select>
665
+ </div>
666
+ {state.timezone === 'Other…' && (
667
+ <div className="task-form-field" style={{ flex: 1 }}>
668
+ <label className="field-label">IANA name</label>
669
+ <input
670
+ className="input"
671
+ type="text"
672
+ placeholder="Europe/Paris"
673
+ value={state.customTimezone}
674
+ onChange={(e) => update('customTimezone', e.target.value)}
675
+ />
676
+ </div>
677
+ )}
678
+ </div>
679
+
680
+ <div className="task-form-row">
681
+ <div className="task-form-field">
682
+ <label className="field-label">Action</label>
683
+ <select
684
+ className="select"
685
+ value={state.actionType}
686
+ onChange={(e) => update('actionType', e.target.value as EditorState['actionType'])}
687
+ >
688
+ {ACTIONS.map((t) => (
689
+ <option key={t} value={t}>{t}</option>
690
+ ))}
691
+ </select>
692
+ </div>
693
+ <div className="task-form-field" style={{ flex: 2 }}>
694
+ <label className="field-label">Target</label>
695
+ <input
696
+ className="input"
697
+ type="text"
698
+ placeholder={state.actionType === 'webhook' ? 'https://...' : state.actionType === 'agent' ? 'agent name or task ref' : 'echo hi'}
699
+ value={state.actionTarget}
700
+ onChange={(e) => update('actionTarget', e.target.value)}
701
+ />
702
+ </div>
703
+ </div>
704
+
705
+ {state.actionType === 'agent' && (
706
+ <div className="task-form-field">
707
+ <label className="field-label">Prompt (what to send the agent)</label>
708
+ <textarea
709
+ className="input"
710
+ rows={3}
711
+ placeholder="Review open PRs for stale code review comments and nudge reviewers."
712
+ value={state.actionPrompt}
713
+ onChange={(e) => update('actionPrompt', e.target.value)}
714
+ />
715
+ </div>
716
+ )}
717
+
718
+ <fieldset className="schedule-budget-card">
719
+ <legend>Budget pre-flight</legend>
720
+ <label className="checkbox-row">
721
+ <input
722
+ type="checkbox"
723
+ checked={state.skipIfBudgetLow}
724
+ onChange={(e) => update('skipIfBudgetLow', e.target.checked)}
725
+ />
726
+ <span>Skip this run when too many background tasks are already running.</span>
727
+ </label>
728
+ <div className="task-form-field">
729
+ <label className="field-label">Max concurrent bg tasks</label>
730
+ <input
731
+ className="input"
732
+ type="number"
733
+ min={1}
734
+ max={64}
735
+ value={state.maxConcurrent}
736
+ onChange={(e) => update('maxConcurrent', parseInt(e.target.value, 10) || 6)}
737
+ disabled={!state.skipIfBudgetLow}
738
+ />
739
+ </div>
740
+ </fieldset>
741
+
742
+ <label className="checkbox-row">
743
+ <input
744
+ type="checkbox"
745
+ checked={state.enabled}
746
+ onChange={(e) => update('enabled', e.target.checked)}
747
+ />
748
+ <span>Enabled</span>
749
+ </label>
750
+
751
+ <div className="modal-footer-actions">
752
+ <Button variant="ghost" type="button" onClick={onClose} disabled={submitting}>
753
+ Cancel
754
+ </Button>
755
+ <Button variant="primary" type="submit" disabled={submitting}>
756
+ {initial ? 'Save' : 'Create'}
757
+ </Button>
758
+ </div>
759
+ </form>
760
+ );
761
+ }
762
+
763
+ /* ------------------------------------------------------------------ */
764
+ /* Helpers */
765
+ /* ------------------------------------------------------------------ */
766
+
767
+ function humanizeSchedule(s: Schedule): string {
768
+ if (s.type !== 'cron') return s.schedule;
769
+ const parsed = parseCronFields(s.schedule);
770
+ if (!parsed) return s.schedule;
771
+ const dowName = DOW_OPTIONS.find((d) => d.value === parsed.dow)?.label || '';
772
+ const hourLabel = HOUR_OPTIONS.find((h) => h.value === parsed.hour)?.label || `${parsed.hour}`;
773
+ const minute = String(parsed.minute).padStart(2, '0');
774
+ if (dowName === 'Every day') return `Every day at ${hourLabel} :${minute}`;
775
+ return `Every ${dowName} at ${hourLabel} :${minute}`;
776
+ }
777
+
778
+ function formatNextRun(iso: string, tz?: string | null): string {
779
+ try {
780
+ return new Date(iso).toLocaleString(undefined, {
781
+ timeZone: tz || undefined,
782
+ weekday: 'long',
783
+ hour: 'numeric',
784
+ minute: '2-digit',
785
+ month: 'short',
786
+ day: 'numeric',
787
+ });
788
+ } catch {
789
+ return new Date(iso).toLocaleString();
790
+ }
791
+ }
792
+
793
+ function resultTagClass(result: string): string {
794
+ if (result === 'success') return 'tag-success';
795
+ if (result === 'skipped') return 'tag-warning';
796
+ return 'tag-error';
797
+ }
798
+
799
+ function truncate(s: string, max: number): string {
800
+ if (s.length <= max) return s;
801
+ return s.slice(0, max - 1) + '…';
802
+ }