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