@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,283 @@
1
+ /**
2
+ * src/server/lib/path-safe.mjs
3
+ *
4
+ * Helpers for the file-browser / scan endpoints that need to accept
5
+ * an arbitrary absolute path from the authenticated client and decide
6
+ * whether it is safe to operate on.
7
+ *
8
+ * The dashboard runs on a developer's machine, so the boundary here
9
+ * is the user's home directory. Any path outside `os.homedir()` (and
10
+ * any explicitly-listed additional allowed root, validated by the
11
+ * caller) is rejected before we ever call `fs` on it.
12
+ *
13
+ * No `..` traversal escapes — `path.resolve` normalizes first, then
14
+ * we check the resolved string against the allow-list.
15
+ */
16
+ import { resolve as pathResolve, sep, isAbsolute } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { randomBytes } from 'node:crypto';
19
+
20
+ /**
21
+ * Normalize an arbitrary list of root paths and dedupe them. Empty /
22
+ * non-string entries are dropped silently so callers can pass
23
+ * `readSettings().dashboard.projectsDirectory` directly.
24
+ *
25
+ * @param {Array<string | null | undefined>} roots
26
+ * @returns {string[]}
27
+ */
28
+ function normalizeRoots(roots) {
29
+ const out = [];
30
+ const seen = new Set();
31
+ for (const raw of roots) {
32
+ if (typeof raw !== 'string') continue;
33
+ const trimmed = raw.trim();
34
+ if (!trimmed) continue;
35
+ const resolved = pathResolve(trimmed);
36
+ if (seen.has(resolved)) continue;
37
+ seen.add(resolved);
38
+ out.push(resolved);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * Build the default allow-list: the user's home plus any explicit
45
+ * extra roots. The caller is expected to validate the extras against
46
+ * home before calling this (we don't try to be clever about Windows
47
+ * reparse points, symlinks, etc. — this is a developer-tool boundary,
48
+ * not a chroot).
49
+ *
50
+ * @param {object} [opts]
51
+ * @param {string} [opts.home] — override for `os.homedir()`
52
+ * @param {Array<string>} [opts.extras] — additional absolute roots (already resolved)
53
+ * @returns {string[]}
54
+ */
55
+ export function defaultAllowedRoots({ home, extras } = {}) {
56
+ return normalizeRoots([home || homedir(), ...(extras || [])]);
57
+ }
58
+
59
+ /**
60
+ * Build the per-request allow-list straight from a settings object.
61
+ * Each configured entry is re-validated against home; entries that
62
+ * fail validation (escape home, empty, non-string, NUL/backslash,
63
+ * …) are silently dropped. The intent is to survive a tampered
64
+ * settings.json: the writer (`validateDashboardSettings`) catches
65
+ * the bad values, but the reader has to be robust against anything
66
+ * on disk.
67
+ *
68
+ * Includes both:
69
+ * - `dashboard.allowedRoots[]` (operator-declared extras)
70
+ * - `dashboard.projectsDirectory` (single legacy extra)
71
+ *
72
+ * @param {object} opts
73
+ * @param {Record<string, unknown> | null | undefined} opts.settings
74
+ * @param {string} [opts.home] — override for `os.homedir()`
75
+ * @returns {string[]}
76
+ */
77
+ export function buildAllowedRootsFromSettings({ settings, home } = {}) {
78
+ const homeResolved = home || homedir();
79
+ const extras = [];
80
+ const configured = settings?.dashboard?.allowedRoots;
81
+ if (Array.isArray(configured)) {
82
+ for (const candidate of configured) {
83
+ if (typeof candidate !== 'string' || !candidate.trim()) continue;
84
+ const safe = resolveSafePath(candidate, [homeResolved]);
85
+ if (safe) extras.push(safe);
86
+ // Silently skip entries that fail validation. The Settings UI
87
+ // (frontend) is responsible for surfacing a warning when a
88
+ // user-typed root is silently dropped.
89
+ }
90
+ }
91
+ const proj = settings?.dashboard?.projectsDirectory;
92
+ if (typeof proj === 'string' && proj.trim()) {
93
+ const safe = resolveSafePath(proj, [homeResolved]);
94
+ if (safe) extras.push(safe);
95
+ }
96
+ return defaultAllowedRoots({ home: homeResolved, extras });
97
+ }
98
+
99
+ /**
100
+ * Resolve and authorize a requested absolute path against an
101
+ * allow-list of roots.
102
+ *
103
+ * Rules:
104
+ * - The input must be a non-empty string.
105
+ * - The path is `path.resolve()`d — `..` segments collapse.
106
+ * - The resolved path must be `===` to one of the allowed roots,
107
+ * OR live strictly beneath it (segment-aware — `pathA` does NOT
108
+ * count as "inside" `pathB` if `pathB` is just a prefix string).
109
+ * - Dotfile / dotdir roots (e.g. `~/.ssh`, `~/.aws`) are rejected
110
+ * even when they live under home. The frontend can still SEE them
111
+ * in listings (so users know the directory exists) but cannot
112
+ * enter them via the browser.
113
+ *
114
+ * Returns `null` on any failure. The caller is expected to translate
115
+ * `null` into a 403 with a clear message.
116
+ *
117
+ * @param {unknown} requestedPath
118
+ * @param {string[]} allowedRoots
119
+ * @returns {string | null}
120
+ */
121
+ export function resolveSafePath(requestedPath, allowedRoots) {
122
+ if (typeof requestedPath !== 'string') return null;
123
+ const trimmed = requestedPath.trim();
124
+ if (!trimmed) return null;
125
+ const resolved = pathResolve(trimmed);
126
+
127
+ const roots = Array.isArray(allowedRoots) ? allowedRoots : [];
128
+ for (const root of roots) {
129
+ if (typeof root !== 'string' || !root) continue;
130
+ const resolvedRoot = pathResolve(root);
131
+ if (resolved === resolvedRoot) {
132
+ if (isDotRoot(resolved)) return null;
133
+ return resolved;
134
+ }
135
+ // Strict segment-aware prefix: ensure a separator boundary so
136
+ // `/home/user/foo` is NOT considered inside `/home/user/foobar`.
137
+ const rootWithSep = resolvedRoot.endsWith(sep)
138
+ ? resolvedRoot
139
+ : resolvedRoot + sep;
140
+ if (resolved.startsWith(rootWithSep)) {
141
+ if (isDotRoot(resolved)) return null;
142
+ return resolved;
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Return `true` if the path is (or lives directly inside) a dotdir
150
+ * at the home root. We deliberately do NOT match deeper dotdirs
151
+ * (`/home/user/projects/.secret` is fine to enter) — only the ones
152
+ * at the top of the home tree are treated as sensitive.
153
+ *
154
+ * @param {string} resolvedPath
155
+ * @param {string} [home]
156
+ * @returns {boolean}
157
+ */
158
+ export function isDotRoot(resolvedPath, home = homedir()) {
159
+ if (typeof resolvedPath !== 'string') return false;
160
+ const homeRoot = pathResolve(home);
161
+ const homeWithSep = homeRoot.endsWith(sep) ? homeRoot : homeRoot + sep;
162
+ if (resolvedPath === homeRoot) return false;
163
+ if (!resolvedPath.startsWith(homeWithSep)) return false;
164
+ const tail = resolvedPath.slice(homeWithSep.length);
165
+ if (!tail) return false;
166
+ // Only first-level dotdirs under home are blocked.
167
+ const first = tail.split(sep)[0];
168
+ return first.startsWith('.') && first.length > 1;
169
+ }
170
+
171
+ // --- Background-agent logPath reconstruction ------------------------------
172
+ //
173
+ // The plugin's `bgr_<id>.json` state files include a `logPath` field
174
+ // built as `${worktree}/.opencode/log/${id}.log`. If `worktree` was
175
+ // empty or `/` when the bg instance was spawned, the resulting
176
+ // `logPath` is broken (e.g. `//.opencode/log/...`) and no log file
177
+ // can ever be written. The bg-retry loop calls into this helper to
178
+ // repair the field before re-dispatching.
179
+ //
180
+ // `deriveAbsoluteBgLogPath(worktree, instanceId)` always returns an
181
+ // absolute path. It falls back to the user's home directory when
182
+ // `worktree` is missing or non-absolute. Never throws — a bad input
183
+ // produces a synthetic `~/.cache/bizar/logs/<id>.log` rather than
184
+ // crashing the retry loop.
185
+ //
186
+ // v3.11.1 — Note on log path correctness:
187
+ // The plugin's `LogWriter` (plugins/bizar/src/report.ts:147) writes
188
+ // to `${logDir}/${sessionId}.log` where `logDir` defaults to
189
+ // `~/.cache/bizar/logs` (plugins/bizar/src/options.ts:88). The
190
+ // bg-spawn tool records a DIFFERENT path —
191
+ // `${worktree}/.opencode/log/${instanceId}.log` — in the state
192
+ // file. Nothing ever writes to that path. This module's
193
+ // `deriveAbsoluteBgLogPath` historically returned the same broken
194
+ // path; the new `getActualBgLogPath` below returns the path the
195
+ // LogWriter actually writes to. Use that for any operator-facing
196
+ // `tail -F` (e.g. the tmux wrap in task-delegator.mjs).
197
+
198
+ const FALLBACK_LOG_DIR = pathResolve(homedir(), '.cache', 'bizar', 'logs');
199
+
200
+ /**
201
+ * Resolve the per-session log directory used by the plugin's
202
+ * `LogWriter`. The plugin's default is `~/.cache/bizar/logs`
203
+ * (configurable via the `logDir` option in `opencode.json`). We
204
+ * honor the `BIZAR_LOG_DIR` env var first, fall back to the
205
+ * plugin's default, and never throw.
206
+ *
207
+ * @param {object} [opts]
208
+ * @param {string} [opts.env] — env-var bag to read (defaults to process.env)
209
+ * @returns {string}
210
+ */
211
+ export function getBgLogDir({ env } = {}) {
212
+ const source = env || process.env;
213
+ const fromEnv = source?.BIZAR_LOG_DIR;
214
+ if (typeof fromEnv === 'string' && fromEnv.trim()) {
215
+ const resolved = pathResolve(fromEnv);
216
+ return resolved;
217
+ }
218
+ return FALLBACK_LOG_DIR;
219
+ }
220
+
221
+ /**
222
+ * Resolve the per-session log path the plugin's `LogWriter` actually
223
+ * writes to. Use this for any operator-facing `tail -F` so the user
224
+ * sees real activity rather than a phantom-file error.
225
+ *
226
+ * Important: the LogWriter keys files by SESSION id (not instance
227
+ * id). When you only have an `instanceId` (e.g. `bg_<sessionId16>`),
228
+ * pass it as `sessionId` — the function will accept any non-empty
229
+ * string and sanitize it.
230
+ *
231
+ * @param {object} [opts]
232
+ * @param {string} [opts.sessionId] — the opencode session id (or any unique key)
233
+ * @param {string} [opts.env] — env-var bag to read
234
+ * @returns {string} absolute path that exists or will exist once the plugin writes
235
+ */
236
+ export function getActualBgLogPath({ sessionId, env } = {}) {
237
+ const safeId = typeof sessionId === 'string' && sessionId.length > 0
238
+ ? sessionId.replace(/[^a-zA-Z0-9_.-]/g, '_')
239
+ : `unknown_${randomBytes(4).toString('hex')}`;
240
+ return pathResolve(getBgLogDir({ env }), `${safeId}.log`);
241
+ }
242
+
243
+ /**
244
+ * @param {unknown} worktree
245
+ * @param {string} instanceId
246
+ * @returns {string}
247
+ */
248
+ export function deriveAbsoluteBgLogPath(worktree, instanceId) {
249
+ const safeId = typeof instanceId === 'string' && instanceId.length > 0
250
+ ? instanceId.replace(/[^a-zA-Z0-9_.-]/g, '_')
251
+ : `unknown_${randomBytes(4).toString('hex')}`;
252
+ const base = typeof worktree === 'string' && worktree.length > 0 && isAbsolute(worktree)
253
+ ? pathResolve(worktree)
254
+ : FALLBACK_LOG_DIR;
255
+ return pathResolve(base, '.opencode', 'log', `${safeId}.log`);
256
+ }
257
+
258
+ /**
259
+ * Decide whether a `logPath` value stored on a bg instance needs to
260
+ * be repaired. A path is considered broken when it is:
261
+ * - not a string
262
+ * - empty
263
+ * - not absolute
264
+ * - contains a literal double-slash sequence (`//` outside of the
265
+ * protocol prefix) — a tell-tale sign of `${empty}/${...}`
266
+ * concatenation
267
+ *
268
+ * @param {unknown} logPath
269
+ * @returns {boolean}
270
+ */
271
+ export function isBrokenBgLogPath(logPath) {
272
+ if (typeof logPath !== 'string' || logPath.length === 0) return true;
273
+ if (!isAbsolute(logPath)) return true;
274
+ // Look for `//` not at the protocol position. Cheap heuristic that
275
+ // catches the user's `//.opencode/log/...` case without requiring
276
+ // a full URL parser.
277
+ const idx = logPath.indexOf('//');
278
+ if (idx === -1) return false;
279
+ // Allow a leading `//` only on Windows-style UNC paths like `\\server\share`.
280
+ // On POSIX, a leading `/` followed by another `/` is always broken.
281
+ if (idx === 0) return true;
282
+ return false;
283
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * src/server/memory-git.mjs
3
+ *
4
+ * Thin wrapper around the `git` binary for the Bizar Memory Service.
5
+ * All operations use `child_process.execFileSync` with array args — never
6
+ * string interpolation — to prevent injection vulnerabilities.
7
+ *
8
+ * Operations: isGitInstalled, clone, pull, commit, push, status, acquireLock,
9
+ * addFile, addAll.
10
+ */
11
+
12
+ import { execFileSync, execSync } from 'node:child_process';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ /**
17
+ * @returns {boolean} true if `git --version` exits 0.
18
+ */
19
+ export function isGitInstalled() {
20
+ try {
21
+ execFileSync('git', ['--version'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Clone a remote repository.
30
+ *
31
+ * @param {string} remote — URL or file path of the remote to clone
32
+ * @param {string} targetDir — absolute path to clone into
33
+ * @param {{ branch?: string, depth?: number }} [opts]
34
+ * @returns {{ ok: boolean, output: string, error?: string }}
35
+ */
36
+ export function clone(remote, targetDir, { branch = 'main', depth = 1 } = {}) {
37
+ try {
38
+ const args = ['clone', '--branch', branch, '--depth', String(depth), remote, targetDir];
39
+ const output = execFileSync('git', args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
40
+ return { ok: true, output };
41
+ } catch (err) {
42
+ return { ok: false, output: '', error: err.message };
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Pull changes into a local working directory.
48
+ *
49
+ * @param {string} dir — working directory (must be a git repo)
50
+ * @param {{ rebase?: boolean }} [opts]
51
+ * @returns {{ ok: boolean, output: string, error?: string }}
52
+ */
53
+ export function pull(dir, { rebase = true } = {}) {
54
+ try {
55
+ const args = rebase ? ['pull', '--rebase'] : ['pull', '--ff-only'];
56
+ const output = execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
57
+ return { ok: true, output };
58
+ } catch (err) {
59
+ return { ok: false, output: '', error: err.message };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Commit staged changes.
65
+ *
66
+ * @param {string} dir — working directory
67
+ * @param {string} message — commit message
68
+ * @param {{ author?: string }} [opts]
69
+ * @returns {{ ok: boolean, output: string, error?: string }}
70
+ */
71
+ export function commit(dir, message, { author } = {}) {
72
+ try {
73
+ const args = ['commit', '-m', message];
74
+ if (author) {
75
+ args.push('--author', author);
76
+ }
77
+ const output = execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
78
+ return { ok: true, output };
79
+ } catch (err) {
80
+ return { ok: false, output: '', error: err.message };
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Push commits to a remote.
86
+ *
87
+ * @param {string} dir
88
+ * @param {{ remote?: string, branch?: string }} [opts]
89
+ * @returns {{ ok: boolean, output: string, error?: string }}
90
+ */
91
+ export function push(dir, { remote = 'origin', branch = 'main' } = {}) {
92
+ try {
93
+ const output = execFileSync(
94
+ 'git',
95
+ ['push', remote, branch],
96
+ { cwd: dir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
97
+ );
98
+ return { ok: true, output };
99
+ } catch (err) {
100
+ return { ok: false, output: '', error: err.message };
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Get the current git status for a repository.
106
+ *
107
+ * @param {string} dir
108
+ * @returns {{ ok: boolean, clean: boolean, branch: string, ahead: number, behind: number, modified: string[], untracked: string[], error?: string }}
109
+ */
110
+ export function status(dir) {
111
+ try {
112
+ // Get branch name
113
+ let branch = 'main';
114
+ try {
115
+ branch = execFileSync('git', ['branch', '--show-current'], {
116
+ cwd: dir,
117
+ encoding: 'utf8',
118
+ stdio: ['pipe', 'pipe', 'pipe'],
119
+ }).trim();
120
+ } catch { /* ignore */ }
121
+
122
+ // Get status --porcelain
123
+ const raw = execFileSync('git', ['status', '--porcelain'], {
124
+ cwd: dir,
125
+ encoding: 'utf8',
126
+ stdio: ['pipe', 'pipe', 'pipe'],
127
+ });
128
+
129
+ const modified = [];
130
+ const untracked = [];
131
+ for (const line of raw.split('\n')) {
132
+ if (!line.trim()) continue;
133
+ const code = line.slice(0, 2);
134
+ const path = line.slice(3);
135
+ if (code === '??') {
136
+ untracked.push(path);
137
+ } else {
138
+ modified.push(path);
139
+ }
140
+ }
141
+
142
+ // Get ahead/behind vs tracking branch
143
+ let ahead = 0;
144
+ let behind = 0;
145
+ try {
146
+ const revparse = execFileSync('git', ['revparse', '--abbrev-ref', '@{upstream}'], {
147
+ cwd: dir,
148
+ encoding: 'utf8',
149
+ stdio: ['pipe', 'pipe', 'pipe'],
150
+ }).trim();
151
+ if (revparse) {
152
+ const aheadBehind = execFileSync('git', ['rev-list', '--left-right', '--count', `${branch}...${revparse}`], {
153
+ cwd: dir,
154
+ encoding: 'utf8',
155
+ stdio: ['pipe', 'pipe', 'pipe'],
156
+ }).trim();
157
+ const [a, b] = aheadBehind.split('\t').map(Number);
158
+ ahead = a || 0;
159
+ behind = b || 0;
160
+ }
161
+ } catch { /* no upstream */ }
162
+
163
+ return {
164
+ ok: true,
165
+ clean: modified.length === 0 && untracked.length === 0,
166
+ branch,
167
+ ahead,
168
+ behind,
169
+ modified,
170
+ untracked,
171
+ };
172
+ } catch (err) {
173
+ return { ok: false, clean: false, branch: 'main', ahead: 0, behind: 0, modified: [], untracked: [], error: err.message };
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Acquire a per-repo mutex lockfile. Returns a release function or an error.
179
+ * The lock file contains { pid, startedAt }. If another live PID holds the lock,
180
+ * returns { error: 'locked' }.
181
+ *
182
+ * @param {string} repoDir — the git repository root
183
+ * @returns {{ release: () => void } | { error: 'locked' }}
184
+ */
185
+ export function acquireLock(repoDir) {
186
+ const lockPath = join(repoDir, '.sync.lock');
187
+ const pid = process.pid;
188
+ const startedAt = Date.now();
189
+
190
+ if (existsSync(lockPath)) {
191
+ try {
192
+ const lockData = JSON.parse(readFileSync(lockPath, 'utf8'));
193
+ // Check if the PID is still alive
194
+ try {
195
+ process.kill(lockData.pid, 0);
196
+ // Still alive — someone else holds the lock
197
+ return { error: 'locked' };
198
+ } catch {
199
+ // PID is dead — stale lock, we'll take over
200
+ }
201
+ } catch { /* corrupt lockfile — take over */ }
202
+ }
203
+
204
+ writeFileSync(lockPath, JSON.stringify({ pid, startedAt }), 'utf8');
205
+
206
+ const release = () => {
207
+ try {
208
+ if (existsSync(lockPath)) {
209
+ const lockData = JSON.parse(readFileSync(lockPath, 'utf8'));
210
+ if (lockData.pid === pid) {
211
+ unlinkSync(lockPath);
212
+ }
213
+ }
214
+ } catch { /* ignore */ }
215
+ };
216
+
217
+ return { release };
218
+ }
219
+
220
+ /**
221
+ * Stage a single file for commit.
222
+ *
223
+ * @param {string} dir — working directory
224
+ * @param {string} filePath — path relative to dir
225
+ * @returns {{ ok: boolean, error?: string }}
226
+ */
227
+ export function addFile(dir, filePath) {
228
+ try {
229
+ execFileSync('git', ['add', filePath], { cwd: dir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
230
+ return { ok: true };
231
+ } catch (err) {
232
+ return { ok: false, error: err.message };
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Stage all changes (including untracked) for commit.
238
+ *
239
+ * @param {string} dir
240
+ * @returns {{ ok: boolean, error?: string }}
241
+ */
242
+ export function addAll(dir) {
243
+ try {
244
+ execFileSync('git', ['add', '-A'], { cwd: dir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
245
+ return { ok: true };
246
+ } catch (err) {
247
+ return { ok: false, error: err.message };
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Set the URL of a git remote, idempotently.
253
+ *
254
+ * @param {string} repoDir — the git repository root
255
+ * @param {string} remoteName — name of the remote (e.g. 'origin')
256
+ * @param {string} url — the remote URL
257
+ * @param {{ overwrite?: boolean }} [opts]
258
+ * @returns {{ ok: true, action: 'added' | 'updated' | 'unchanged' }}
259
+ * @throws if the remote already exists with a different URL and overwrite is false,
260
+ * or if the underlying git command fails.
261
+ */
262
+ export function addRemote(repoDir, remoteName, url, { overwrite = false } = {}) {
263
+ let existing = null;
264
+ try {
265
+ existing = execFileSync('git', ['remote', 'get-url', remoteName], {
266
+ cwd: repoDir,
267
+ encoding: 'utf8',
268
+ timeout: 5000,
269
+ stdio: ['pipe', 'pipe', 'pipe'],
270
+ }).trim();
271
+ } catch (err) {
272
+ // exit code 2 or 128 means remote doesn't exist; anything else is a real error
273
+ if (err.status !== 2 && err.status !== 128) {
274
+ throw new Error(`failed to query remote '${remoteName}': ${err.message}`);
275
+ }
276
+ }
277
+
278
+ if (existing && existing === url) {
279
+ return { ok: true, action: 'unchanged' };
280
+ }
281
+
282
+ if (existing && !overwrite) {
283
+ throw new Error(
284
+ `remote '${remoteName}' already exists with URL ${existing}.\n` +
285
+ ` To update it, call addRemote(dir, name, url, { overwrite: true }).`,
286
+ );
287
+ }
288
+
289
+ if (existing) {
290
+ execFileSync('git', ['remote', 'set-url', remoteName, url], {
291
+ cwd: repoDir,
292
+ timeout: 5000,
293
+ stdio: ['pipe', 'pipe', 'pipe'],
294
+ });
295
+ return { ok: true, action: 'updated' };
296
+ }
297
+
298
+ execFileSync('git', ['remote', 'add', remoteName, url], {
299
+ cwd: repoDir,
300
+ timeout: 5000,
301
+ stdio: ['pipe', 'pipe', 'pipe'],
302
+ });
303
+ return { ok: true, action: 'added' };
304
+ }
305
+
306
+ /**
307
+ * Test whether a git remote is reachable.
308
+ *
309
+ * @param {string} repoDir — the git repository root
310
+ * @param {string} remoteName — name of the remote (e.g. 'origin')
311
+ * @param {{ timeoutMs?: number }} [opts]
312
+ * @returns {string} the trimmed stdout from `git ls-remote` on success,
313
+ * or '' on any failure (network, auth, timeout).
314
+ * Does NOT throw.
315
+ */
316
+ export function lsRemote(repoDir, remoteName, { timeoutMs = 5000 } = {}) {
317
+ try {
318
+ const stdout = execFileSync('git', ['ls-remote', remoteName], {
319
+ cwd: repoDir,
320
+ timeout: timeoutMs,
321
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
322
+ encoding: 'utf8',
323
+ stdio: ['pipe', 'pipe', 'pipe'],
324
+ });
325
+ return stdout.trim();
326
+ } catch {
327
+ return '';
328
+ }
329
+ }