@otto-assistant/bridge 0.4.92
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/bin.js +2 -0
- package/dist/agent-model.e2e.test.js +755 -0
- package/dist/ai-tool-to-genai.js +233 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/ai-tool.js +6 -0
- package/dist/anthropic-auth-plugin.js +728 -0
- package/dist/anthropic-auth-plugin.test.js +125 -0
- package/dist/anthropic-auth-state.js +231 -0
- package/dist/bin.js +90 -0
- package/dist/channel-management.js +227 -0
- package/dist/cli-parsing.test.js +137 -0
- package/dist/cli-send-thread.e2e.test.js +356 -0
- package/dist/cli.js +3276 -0
- package/dist/commands/abort.js +65 -0
- package/dist/commands/action-buttons.js +245 -0
- package/dist/commands/add-project.js +113 -0
- package/dist/commands/agent.js +335 -0
- package/dist/commands/ask-question.js +274 -0
- package/dist/commands/btw.js +116 -0
- package/dist/commands/compact.js +120 -0
- package/dist/commands/context-usage.js +140 -0
- package/dist/commands/create-new-project.js +130 -0
- package/dist/commands/diff.js +63 -0
- package/dist/commands/file-upload.js +275 -0
- package/dist/commands/fork.js +220 -0
- package/dist/commands/gemini-apikey.js +70 -0
- package/dist/commands/login.js +885 -0
- package/dist/commands/mcp.js +239 -0
- package/dist/commands/memory-snapshot.js +24 -0
- package/dist/commands/mention-mode.js +44 -0
- package/dist/commands/merge-worktree.js +159 -0
- package/dist/commands/model-variant.js +364 -0
- package/dist/commands/model.js +776 -0
- package/dist/commands/new-worktree.js +366 -0
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/permissions.js +274 -0
- package/dist/commands/queue.js +206 -0
- package/dist/commands/remove-project.js +115 -0
- package/dist/commands/restart-opencode-server.js +127 -0
- package/dist/commands/resume.js +149 -0
- package/dist/commands/run-command.js +79 -0
- package/dist/commands/screenshare.js +303 -0
- package/dist/commands/screenshare.test.js +20 -0
- package/dist/commands/session-id.js +78 -0
- package/dist/commands/session.js +176 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +305 -0
- package/dist/commands/unset-model.js +138 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/user-command.js +155 -0
- package/dist/commands/verbosity.js +125 -0
- package/dist/commands/worktree-settings.js +43 -0
- package/dist/commands/worktrees.js +410 -0
- package/dist/condense-memory.js +33 -0
- package/dist/config.js +94 -0
- package/dist/context-awareness-plugin.js +363 -0
- package/dist/context-awareness-plugin.test.js +124 -0
- package/dist/critique-utils.js +95 -0
- package/dist/database.js +1310 -0
- package/dist/db.js +251 -0
- package/dist/db.test.js +138 -0
- package/dist/debounce-timeout.js +28 -0
- package/dist/debounced-process-flush.js +77 -0
- package/dist/discord-bot.js +1008 -0
- package/dist/discord-command-registration.js +524 -0
- package/dist/discord-urls.js +81 -0
- package/dist/discord-utils.js +591 -0
- package/dist/discord-utils.test.js +134 -0
- package/dist/errors.js +157 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/event-stream-real-capture.e2e.test.js +533 -0
- package/dist/eventsource-parser.test.js +327 -0
- package/dist/exec-async.js +26 -0
- package/dist/external-opencode-sync.js +480 -0
- package/dist/format-tables.js +302 -0
- package/dist/format-tables.test.js +308 -0
- package/dist/forum-sync/config.js +79 -0
- package/dist/forum-sync/discord-operations.js +154 -0
- package/dist/forum-sync/index.js +5 -0
- package/dist/forum-sync/markdown.js +113 -0
- package/dist/forum-sync/sync-to-discord.js +417 -0
- package/dist/forum-sync/sync-to-files.js +190 -0
- package/dist/forum-sync/types.js +53 -0
- package/dist/forum-sync/watchers.js +307 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
- package/dist/gateway-proxy.e2e.test.js +483 -0
- package/dist/genai-worker-wrapper.js +111 -0
- package/dist/genai-worker.js +311 -0
- package/dist/genai.js +232 -0
- package/dist/generated/browser.js +17 -0
- package/dist/generated/client.js +37 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.js +52 -0
- package/dist/generated/internal/class.js +49 -0
- package/dist/generated/internal/prismaNamespace.js +253 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +223 -0
- package/dist/generated/models/bot_api_keys.js +1 -0
- package/dist/generated/models/bot_tokens.js +1 -0
- package/dist/generated/models/channel_agents.js +1 -0
- package/dist/generated/models/channel_directories.js +1 -0
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/channel_models.js +1 -0
- package/dist/generated/models/channel_verbosity.js +1 -0
- package/dist/generated/models/channel_worktrees.js +1 -0
- package/dist/generated/models/forum_sync_configs.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/generated/models/ipc_requests.js +1 -0
- package/dist/generated/models/part_messages.js +1 -0
- package/dist/generated/models/scheduled_tasks.js +1 -0
- package/dist/generated/models/session_agents.js +1 -0
- package/dist/generated/models/session_events.js +1 -0
- package/dist/generated/models/session_models.js +1 -0
- package/dist/generated/models/session_start_sources.js +1 -0
- package/dist/generated/models/thread_sessions.js +1 -0
- package/dist/generated/models/thread_worktrees.js +1 -0
- package/dist/generated/models.js +1 -0
- package/dist/heap-monitor.js +122 -0
- package/dist/hrana-server.js +263 -0
- package/dist/hrana-server.test.js +370 -0
- package/dist/html-actions.js +123 -0
- package/dist/html-actions.test.js +70 -0
- package/dist/html-components.js +117 -0
- package/dist/html-components.test.js +34 -0
- package/dist/image-optimizer-plugin.js +153 -0
- package/dist/image-utils.js +112 -0
- package/dist/interaction-handler.js +397 -0
- package/dist/ipc-polling.js +252 -0
- package/dist/ipc-tools-plugin.js +193 -0
- package/dist/kimaki-digital-twin.e2e.test.js +161 -0
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +87 -0
- package/dist/kimaki-opencode-plugin.js +17 -0
- package/dist/kimaki-opencode-plugin.test.js +98 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +165 -0
- package/dist/markdown.js +342 -0
- package/dist/markdown.test.js +257 -0
- package/dist/message-finish-field.e2e.test.js +165 -0
- package/dist/message-formatting.js +413 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/message-preprocessing.js +330 -0
- package/dist/onboarding-tutorial.js +172 -0
- package/dist/onboarding-welcome.js +37 -0
- package/dist/openai-realtime.js +224 -0
- package/dist/opencode-command-detection.js +65 -0
- package/dist/opencode-command-detection.test.js +240 -0
- package/dist/opencode-command.js +129 -0
- package/dist/opencode-command.test.js +48 -0
- package/dist/opencode-interrupt-plugin.js +361 -0
- package/dist/opencode-interrupt-plugin.test.js +458 -0
- package/dist/opencode.js +861 -0
- package/dist/otto/branding.js +22 -0
- package/dist/otto/index.js +21 -0
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/patch-text-parser.js +97 -0
- package/dist/plugin-logger.js +59 -0
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/queue-advanced-abort.e2e.test.js +293 -0
- package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
- package/dist/queue-advanced-e2e-setup.js +786 -0
- package/dist/queue-advanced-footer.e2e.test.js +472 -0
- package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +180 -0
- package/dist/queue-advanced-question.e2e.test.js +261 -0
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
- package/dist/queue-advanced-typing.e2e.test.js +153 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/queue-interrupt-drain.e2e.test.js +135 -0
- package/dist/queue-question-select-drain.e2e.test.js +120 -0
- package/dist/runtime-idle-sweeper.js +52 -0
- package/dist/runtime-lifecycle.e2e.test.js +508 -0
- package/dist/sentry.js +23 -0
- package/dist/session-handler/agent-utils.js +67 -0
- package/dist/session-handler/event-stream-state.js +420 -0
- package/dist/session-handler/event-stream-state.test.js +563 -0
- package/dist/session-handler/model-utils.js +124 -0
- package/dist/session-handler/opencode-session-event-log.js +94 -0
- package/dist/session-handler/thread-runtime-state.js +104 -0
- package/dist/session-handler/thread-session-runtime.js +3258 -0
- package/dist/session-handler.js +9 -0
- package/dist/session-search.js +100 -0
- package/dist/session-search.test.js +40 -0
- package/dist/session-title-rename.test.js +80 -0
- package/dist/startup-service.js +153 -0
- package/dist/startup-time.e2e.test.js +296 -0
- package/dist/store.js +17 -0
- package/dist/system-message.js +613 -0
- package/dist/system-message.test.js +602 -0
- package/dist/task-runner.js +295 -0
- package/dist/task-schedule.js +209 -0
- package/dist/task-schedule.test.js +71 -0
- package/dist/test-utils.js +299 -0
- package/dist/thinking-utils.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +999 -0
- package/dist/tools.js +357 -0
- package/dist/undo-redo.e2e.test.js +161 -0
- package/dist/unnest-code-blocks.js +146 -0
- package/dist/unnest-code-blocks.test.js +673 -0
- package/dist/upgrade.js +114 -0
- package/dist/utils.js +144 -0
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +646 -0
- package/dist/voice-message.e2e.test.js +1021 -0
- package/dist/voice.js +447 -0
- package/dist/voice.test.js +235 -0
- package/dist/wait-session.js +94 -0
- package/dist/websockify.js +69 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-lifecycle.e2e.test.js +308 -0
- package/dist/worktree-utils.js +3 -0
- package/dist/worktrees.js +929 -0
- package/dist/worktrees.test.js +189 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +98 -0
- package/schema.prisma +295 -0
- package/skills/batch/SKILL.md +87 -0
- package/skills/critique/SKILL.md +112 -0
- package/skills/egaki/SKILL.md +100 -0
- package/skills/errore/SKILL.md +647 -0
- package/skills/event-sourcing-state/SKILL.md +252 -0
- package/skills/gitchamber/SKILL.md +93 -0
- package/skills/goke/SKILL.md +644 -0
- package/skills/jitter/EDITOR.md +219 -0
- package/skills/jitter/EXPORT-INTERNALS.md +309 -0
- package/skills/jitter/SKILL.md +158 -0
- package/skills/jitter/jitter-clipboard.json +1042 -0
- package/skills/jitter/package.json +14 -0
- package/skills/jitter/tsconfig.json +15 -0
- package/skills/jitter/utils/actions.ts +212 -0
- package/skills/jitter/utils/export.ts +114 -0
- package/skills/jitter/utils/index.ts +141 -0
- package/skills/jitter/utils/snapshot.ts +154 -0
- package/skills/jitter/utils/traverse.ts +246 -0
- package/skills/jitter/utils/types.ts +279 -0
- package/skills/jitter/utils/wait.ts +133 -0
- package/skills/lintcn/SKILL.md +873 -0
- package/skills/new-skill/SKILL.md +211 -0
- package/skills/npm-package/SKILL.md +239 -0
- package/skills/playwriter/SKILL.md +35 -0
- package/skills/proxyman/SKILL.md +215 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/termcast/SKILL.md +945 -0
- package/skills/tuistory/SKILL.md +250 -0
- package/skills/usecomputer/SKILL.md +264 -0
- package/skills/x-articles/SKILL.md +554 -0
- package/skills/zele/SKILL.md +112 -0
- package/skills/zustand-centralized-state/SKILL.md +1004 -0
- package/src/agent-model.e2e.test.ts +976 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +283 -0
- package/src/ai-tool.ts +39 -0
- package/src/anthropic-auth-plugin.test.ts +159 -0
- package/src/anthropic-auth-plugin.ts +861 -0
- package/src/anthropic-auth-state.ts +282 -0
- package/src/bin.ts +111 -0
- package/src/channel-management.ts +334 -0
- package/src/cli-parsing.test.ts +195 -0
- package/src/cli-send-thread.e2e.test.ts +464 -0
- package/src/cli.ts +4581 -0
- package/src/commands/abort.ts +89 -0
- package/src/commands/action-buttons.ts +364 -0
- package/src/commands/add-project.ts +149 -0
- package/src/commands/agent.ts +473 -0
- package/src/commands/ask-question.ts +390 -0
- package/src/commands/btw.ts +164 -0
- package/src/commands/compact.ts +157 -0
- package/src/commands/context-usage.ts +199 -0
- package/src/commands/create-new-project.ts +190 -0
- package/src/commands/diff.ts +91 -0
- package/src/commands/file-upload.ts +389 -0
- package/src/commands/fork.ts +321 -0
- package/src/commands/gemini-apikey.ts +104 -0
- package/src/commands/login.ts +1173 -0
- package/src/commands/mcp.ts +307 -0
- package/src/commands/memory-snapshot.ts +30 -0
- package/src/commands/mention-mode.ts +68 -0
- package/src/commands/merge-worktree.ts +223 -0
- package/src/commands/model-variant.ts +483 -0
- package/src/commands/model.ts +1053 -0
- package/src/commands/new-worktree.ts +510 -0
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/permissions.ts +397 -0
- package/src/commands/queue.ts +271 -0
- package/src/commands/remove-project.ts +155 -0
- package/src/commands/restart-opencode-server.ts +162 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/run-command.ts +123 -0
- package/src/commands/screenshare.test.ts +30 -0
- package/src/commands/screenshare.ts +366 -0
- package/src/commands/session-id.ts +109 -0
- package/src/commands/session.ts +227 -0
- package/src/commands/share.ts +106 -0
- package/src/commands/tasks.ts +293 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +386 -0
- package/src/commands/unset-model.ts +173 -0
- package/src/commands/upgrade.ts +52 -0
- package/src/commands/user-command.ts +198 -0
- package/src/commands/verbosity.ts +173 -0
- package/src/commands/worktree-settings.ts +70 -0
- package/src/commands/worktrees.ts +552 -0
- package/src/condense-memory.ts +36 -0
- package/src/config.ts +111 -0
- package/src/context-awareness-plugin.test.ts +142 -0
- package/src/context-awareness-plugin.ts +510 -0
- package/src/critique-utils.ts +139 -0
- package/src/database.ts +1876 -0
- package/src/db.test.ts +162 -0
- package/src/db.ts +286 -0
- package/src/debounce-timeout.ts +43 -0
- package/src/debounced-process-flush.ts +104 -0
- package/src/discord-bot.ts +1330 -0
- package/src/discord-command-registration.ts +693 -0
- package/src/discord-urls.ts +88 -0
- package/src/discord-utils.test.ts +153 -0
- package/src/discord-utils.ts +800 -0
- package/src/errors.ts +201 -0
- package/src/escape-backticks.test.ts +469 -0
- package/src/event-stream-real-capture.e2e.test.ts +692 -0
- package/src/eventsource-parser.test.ts +351 -0
- package/src/exec-async.ts +35 -0
- package/src/external-opencode-sync.ts +685 -0
- package/src/format-tables.test.ts +335 -0
- package/src/format-tables.ts +445 -0
- package/src/forum-sync/config.ts +92 -0
- package/src/forum-sync/discord-operations.ts +241 -0
- package/src/forum-sync/index.ts +9 -0
- package/src/forum-sync/markdown.ts +172 -0
- package/src/forum-sync/sync-to-discord.ts +595 -0
- package/src/forum-sync/sync-to-files.ts +294 -0
- package/src/forum-sync/types.ts +175 -0
- package/src/forum-sync/watchers.ts +454 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
- package/src/gateway-proxy.e2e.test.ts +640 -0
- package/src/genai-worker-wrapper.ts +164 -0
- package/src/genai-worker.ts +386 -0
- package/src/genai.ts +321 -0
- package/src/generated/browser.ts +114 -0
- package/src/generated/client.ts +138 -0
- package/src/generated/commonInputTypes.ts +736 -0
- package/src/generated/enums.ts +88 -0
- package/src/generated/internal/class.ts +384 -0
- package/src/generated/internal/prismaNamespace.ts +2386 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +326 -0
- package/src/generated/models/bot_api_keys.ts +1288 -0
- package/src/generated/models/bot_tokens.ts +1656 -0
- package/src/generated/models/channel_agents.ts +1256 -0
- package/src/generated/models/channel_directories.ts +1859 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/channel_models.ts +1288 -0
- package/src/generated/models/channel_verbosity.ts +1228 -0
- package/src/generated/models/channel_worktrees.ts +1300 -0
- package/src/generated/models/forum_sync_configs.ts +1452 -0
- package/src/generated/models/global_models.ts +1288 -0
- package/src/generated/models/ipc_requests.ts +1485 -0
- package/src/generated/models/part_messages.ts +1302 -0
- package/src/generated/models/scheduled_tasks.ts +2320 -0
- package/src/generated/models/session_agents.ts +1086 -0
- package/src/generated/models/session_events.ts +1439 -0
- package/src/generated/models/session_models.ts +1114 -0
- package/src/generated/models/session_start_sources.ts +1408 -0
- package/src/generated/models/thread_sessions.ts +1781 -0
- package/src/generated/models/thread_worktrees.ts +1356 -0
- package/src/generated/models.ts +30 -0
- package/src/heap-monitor.ts +152 -0
- package/src/hrana-server.test.ts +434 -0
- package/src/hrana-server.ts +314 -0
- package/src/html-actions.test.ts +87 -0
- package/src/html-actions.ts +174 -0
- package/src/html-components.test.ts +38 -0
- package/src/html-components.ts +181 -0
- package/src/image-optimizer-plugin.ts +194 -0
- package/src/image-utils.ts +149 -0
- package/src/interaction-handler.ts +576 -0
- package/src/ipc-polling.ts +326 -0
- package/src/ipc-tools-plugin.ts +236 -0
- package/src/kimaki-digital-twin.e2e.test.ts +199 -0
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +109 -0
- package/src/kimaki-opencode-plugin.test.ts +108 -0
- package/src/kimaki-opencode-plugin.ts +18 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +208 -0
- package/src/markdown.test.ts +308 -0
- package/src/markdown.ts +410 -0
- package/src/message-finish-field.e2e.test.ts +192 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +533 -0
- package/src/message-preprocessing.ts +455 -0
- package/src/onboarding-tutorial.ts +176 -0
- package/src/onboarding-welcome.ts +49 -0
- package/src/openai-realtime.ts +358 -0
- package/src/opencode-command-detection.test.ts +307 -0
- package/src/opencode-command-detection.ts +76 -0
- package/src/opencode-command.test.ts +70 -0
- package/src/opencode-command.ts +188 -0
- package/src/opencode-interrupt-plugin.test.ts +677 -0
- package/src/opencode-interrupt-plugin.ts +477 -0
- package/src/opencode.ts +1110 -0
- package/src/otto/branding.ts +23 -0
- package/src/otto/index.ts +22 -0
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/patch-text-parser.ts +107 -0
- package/src/plugin-logger.ts +68 -0
- package/src/privacy-sanitizer.ts +142 -0
- package/src/queue-advanced-abort.e2e.test.ts +382 -0
- package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
- package/src/queue-advanced-e2e-setup.ts +873 -0
- package/src/queue-advanced-footer.e2e.test.ts +576 -0
- package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +245 -0
- package/src/queue-advanced-question.e2e.test.ts +316 -0
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
- package/src/queue-advanced-typing.e2e.test.ts +199 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/queue-interrupt-drain.e2e.test.ts +166 -0
- package/src/queue-question-select-drain.e2e.test.ts +152 -0
- package/src/runtime-idle-sweeper.ts +76 -0
- package/src/runtime-lifecycle.e2e.test.ts +641 -0
- package/src/schema.sql +173 -0
- package/src/sentry.ts +26 -0
- package/src/session-handler/agent-utils.ts +97 -0
- package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
- package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
- package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
- package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
- package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
- package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
- package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
- package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
- package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
- package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
- package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
- package/src/session-handler/event-stream-state.test.ts +645 -0
- package/src/session-handler/event-stream-state.ts +608 -0
- package/src/session-handler/model-utils.ts +183 -0
- package/src/session-handler/opencode-session-event-log.ts +130 -0
- package/src/session-handler/thread-runtime-state.ts +212 -0
- package/src/session-handler/thread-session-runtime.ts +4281 -0
- package/src/session-handler.ts +15 -0
- package/src/session-search.test.ts +50 -0
- package/src/session-search.ts +148 -0
- package/src/session-title-rename.test.ts +112 -0
- package/src/startup-service.ts +200 -0
- package/src/startup-time.e2e.test.ts +373 -0
- package/src/store.ts +122 -0
- package/src/system-message.test.ts +612 -0
- package/src/system-message.ts +723 -0
- package/src/task-runner.ts +421 -0
- package/src/task-schedule.test.ts +84 -0
- package/src/task-schedule.ts +311 -0
- package/src/test-utils.ts +435 -0
- package/src/thinking-utils.ts +61 -0
- package/src/thread-message-queue.e2e.test.ts +1219 -0
- package/src/tools.ts +430 -0
- package/src/undici.d.ts +12 -0
- package/src/undo-redo.e2e.test.ts +209 -0
- package/src/unnest-code-blocks.test.ts +713 -0
- package/src/unnest-code-blocks.ts +185 -0
- package/src/upgrade.ts +127 -0
- package/src/utils.ts +212 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +908 -0
- package/src/voice-message.e2e.test.ts +1255 -0
- package/src/voice.test.ts +281 -0
- package/src/voice.ts +627 -0
- package/src/wait-session.ts +147 -0
- package/src/websockify.ts +101 -0
- package/src/worker-types.ts +64 -0
- package/src/worktree-lifecycle.e2e.test.ts +391 -0
- package/src/worktree-utils.ts +4 -0
- package/src/worktrees.test.ts +223 -0
- package/src/worktrees.ts +1294 -0
- package/src/xml.test.ts +38 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
// Core Discord bot module that handles message events and bot lifecycle.
|
|
2
|
+
// Bridges Discord messages to OpenCode sessions, manages voice connections,
|
|
3
|
+
// and orchestrates the main event loop for the Kimaki bot.
|
|
4
|
+
import { initDatabase, closeDatabase, getThreadWorktree, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, getPrisma, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorktree, setWorktreeReady, } from './database.js';
|
|
5
|
+
import { stopOpencodeServer, } from './opencode.js';
|
|
6
|
+
import { formatWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
|
|
7
|
+
import { validateWorktreeDirectory, git } from './worktrees.js';
|
|
8
|
+
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
9
|
+
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
|
|
10
|
+
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
11
|
+
import YAML from 'yaml';
|
|
12
|
+
import { getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
13
|
+
import { isVoiceAttachment } from './voice-attachment.js';
|
|
14
|
+
import { preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
|
|
15
|
+
import { cancelPendingActionButtons } from './commands/action-buttons.js';
|
|
16
|
+
import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
|
|
17
|
+
import { cancelPendingFileUpload } from './commands/file-upload.js';
|
|
18
|
+
import { cancelPendingPermission } from './commands/permissions.js';
|
|
19
|
+
import { cancelHtmlActionsForThread } from './html-actions.js';
|
|
20
|
+
import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
|
|
21
|
+
import { voiceConnections, cleanupVoiceConnection, registerVoiceStateHandler, } from './voice-handler.js';
|
|
22
|
+
import {} from './session-handler/model-utils.js';
|
|
23
|
+
import { getRuntime, getOrCreateRuntime, disposeRuntime, } from './session-handler/thread-session-runtime.js';
|
|
24
|
+
import { runShellCommand } from './commands/run-command.js';
|
|
25
|
+
import { registerInteractionHandler } from './interaction-handler.js';
|
|
26
|
+
import { getDiscordRestApiUrl } from './discord-urls.js';
|
|
27
|
+
import { markDiscordGatewayReady, stopHranaServer } from './hrana-server.js';
|
|
28
|
+
import { notifyError } from './sentry.js';
|
|
29
|
+
import { flushDebouncedProcessCallbacks } from './debounced-process-flush.js';
|
|
30
|
+
import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js';
|
|
31
|
+
import { startExternalOpencodeSessionSync, stopExternalOpencodeSessionSync, } from './external-opencode-sync.js';
|
|
32
|
+
export { initDatabase, closeDatabase, getChannelDirectory, getPrisma, } from './database.js';
|
|
33
|
+
export { initializeOpencodeForDirectory } from './opencode.js';
|
|
34
|
+
export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, } from './discord-utils.js';
|
|
35
|
+
export { getOpencodeSystemMessage } from './system-message.js';
|
|
36
|
+
export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, createDefaultKimakiChannel, getChannelsWithDescriptions, } from './channel-management.js';
|
|
37
|
+
import { ChannelType, Client, Events, GatewayIntentBits, Partials, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import * as errore from 'errore';
|
|
41
|
+
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
42
|
+
import { writeHeapSnapshot, startHeapMonitor } from './heap-monitor.js';
|
|
43
|
+
import { startTaskRunner } from './task-runner.js';
|
|
44
|
+
// Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
|
|
45
|
+
// Each session's event.subscribe() holds a connection; without enough connections,
|
|
46
|
+
// regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
|
|
47
|
+
// undici is a transitive dep from discord.js — not listed in our package.json.
|
|
48
|
+
// Types are declared in src/undici.d.ts.
|
|
49
|
+
const discordLogger = createLogger(LogPrefix.DISCORD);
|
|
50
|
+
const voiceLogger = createLogger(LogPrefix.VOICE);
|
|
51
|
+
// Well-known WebSocket and Discord Gateway close codes for diagnostic logging.
|
|
52
|
+
// Gateway proxy redeploys cause an abrupt TCP drop (code 1006) because the proxy
|
|
53
|
+
// doesn't send a close frame to clients before shutting down. discord.js then
|
|
54
|
+
// enters reconnection mode. The ShardReconnecting event intentionally strips the
|
|
55
|
+
// close code for recoverable disconnects, so we track it ourselves from the
|
|
56
|
+
// lower-level ShardDisconnect and ShardError events and correlate by shard ID.
|
|
57
|
+
function describeCloseCode(code) {
|
|
58
|
+
const codes = {
|
|
59
|
+
1000: 'normal closure',
|
|
60
|
+
1001: 'going away',
|
|
61
|
+
1006: 'abnormal closure (no close frame received)',
|
|
62
|
+
1011: 'unexpected server error',
|
|
63
|
+
1012: 'service restart',
|
|
64
|
+
4000: 'unknown error',
|
|
65
|
+
4001: 'unknown opcode',
|
|
66
|
+
4002: 'decode error',
|
|
67
|
+
4003: 'not authenticated',
|
|
68
|
+
4004: 'authentication failed',
|
|
69
|
+
4005: 'already authenticated',
|
|
70
|
+
4007: 'invalid seq',
|
|
71
|
+
4008: 'rate limited',
|
|
72
|
+
4009: 'session timed out',
|
|
73
|
+
4010: 'invalid shard',
|
|
74
|
+
4011: 'sharding required',
|
|
75
|
+
4012: 'invalid API version',
|
|
76
|
+
4013: 'invalid intents',
|
|
77
|
+
4014: 'disallowed intents',
|
|
78
|
+
};
|
|
79
|
+
return codes[code] || 'unknown';
|
|
80
|
+
}
|
|
81
|
+
const shardReconnectState = new Map();
|
|
82
|
+
function getOrCreateShardState(shardId) {
|
|
83
|
+
let state = shardReconnectState.get(shardId);
|
|
84
|
+
if (!state) {
|
|
85
|
+
state = { attempts: 0 };
|
|
86
|
+
shardReconnectState.set(shardId, state);
|
|
87
|
+
}
|
|
88
|
+
return state;
|
|
89
|
+
}
|
|
90
|
+
function parseEmbedFooterMarker({ footer, }) {
|
|
91
|
+
if (!footer) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const parsed = YAML.parse(footer);
|
|
96
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function parseSessionStartSourceFromMarker(marker) {
|
|
106
|
+
if (!marker?.scheduledKind) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
if (marker.scheduledKind !== 'at' && marker.scheduledKind !== 'cron') {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
if (typeof marker.scheduledTaskId !== 'number' ||
|
|
113
|
+
!Number.isInteger(marker.scheduledTaskId) ||
|
|
114
|
+
marker.scheduledTaskId < 1) {
|
|
115
|
+
return { scheduleKind: marker.scheduledKind };
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
scheduleKind: marker.scheduledKind,
|
|
119
|
+
scheduledTaskId: marker.scheduledTaskId,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export async function createDiscordClient() {
|
|
123
|
+
// Read REST API URL lazily so gateway mode can set store.discordBaseUrl
|
|
124
|
+
// after module import but before client creation.
|
|
125
|
+
const restApiUrl = getDiscordRestApiUrl();
|
|
126
|
+
return new Client({
|
|
127
|
+
intents: [
|
|
128
|
+
GatewayIntentBits.Guilds,
|
|
129
|
+
GatewayIntentBits.GuildMessages,
|
|
130
|
+
GatewayIntentBits.MessageContent,
|
|
131
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
132
|
+
],
|
|
133
|
+
partials: [
|
|
134
|
+
Partials.Channel,
|
|
135
|
+
Partials.Message,
|
|
136
|
+
Partials.User,
|
|
137
|
+
Partials.ThreadMember,
|
|
138
|
+
],
|
|
139
|
+
rest: { api: restApiUrl },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
export async function startDiscordBot({ token, appId, discordClient, useWorktrees, }) {
|
|
143
|
+
if (!discordClient) {
|
|
144
|
+
discordClient = await createDiscordClient();
|
|
145
|
+
}
|
|
146
|
+
let currentAppId = appId;
|
|
147
|
+
const setupHandlers = async (c) => {
|
|
148
|
+
discordLogger.log(`Discord bot logged in as ${c.user.tag}`);
|
|
149
|
+
discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`);
|
|
150
|
+
discordLogger.log(`Bot user ID: ${c.user.id}`);
|
|
151
|
+
if (!currentAppId) {
|
|
152
|
+
await c.application?.fetch();
|
|
153
|
+
currentAppId = c.application?.id;
|
|
154
|
+
if (!currentAppId) {
|
|
155
|
+
discordLogger.error('Could not get application ID');
|
|
156
|
+
throw new Error('Failed to get bot application ID');
|
|
157
|
+
}
|
|
158
|
+
discordLogger.log(`Bot Application ID (fetched): ${currentAppId}`);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
discordLogger.log(`Bot Application ID (provided): ${currentAppId}`);
|
|
162
|
+
}
|
|
163
|
+
voiceLogger.log('[READY] Bot is ready');
|
|
164
|
+
markDiscordGatewayReady();
|
|
165
|
+
registerInteractionHandler({ discordClient: c, appId: currentAppId });
|
|
166
|
+
registerVoiceStateHandler({ discordClient: c, appId: currentAppId });
|
|
167
|
+
startExternalOpencodeSessionSync({ discordClient: c });
|
|
168
|
+
// Channel logging is informational only; do it in background so startup stays responsive.
|
|
169
|
+
void (async () => {
|
|
170
|
+
for (const guild of c.guilds.cache.values()) {
|
|
171
|
+
discordLogger.log(`${guild.name} (${guild.id})`);
|
|
172
|
+
const channels = await getChannelsWithDescriptions(guild);
|
|
173
|
+
const kimakiChannels = channels.filter((ch) => ch.kimakiDirectory);
|
|
174
|
+
if (kimakiChannels.length > 0) {
|
|
175
|
+
discordLogger.log(` Found ${kimakiChannels.length} channel(s) for this bot`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
discordLogger.log(' No channels for this bot');
|
|
179
|
+
}
|
|
180
|
+
})().catch((error) => {
|
|
181
|
+
discordLogger.warn(`Background guild channel scan failed: ${error instanceof Error ? error.stack : String(error)}`);
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
// If client is already ready (was logged in before being passed to us),
|
|
185
|
+
// run setup immediately. Otherwise wait for the ClientReady event.
|
|
186
|
+
if (discordClient.isReady()) {
|
|
187
|
+
await setupHandlers(discordClient);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
discordClient.once(Events.ClientReady, (readyClient) => {
|
|
191
|
+
void setupHandlers(readyClient).catch((error) => {
|
|
192
|
+
discordLogger.error(`[GATEWAY] ClientReady handler failed: ${formatErrorWithStack(error)}`);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
discordClient.on(Events.Error, (error) => {
|
|
197
|
+
discordLogger.error('[GATEWAY] Client error:', formatErrorWithStack(error));
|
|
198
|
+
});
|
|
199
|
+
discordClient.on(Events.ShardError, (error, shardId) => {
|
|
200
|
+
const state = getOrCreateShardState(shardId);
|
|
201
|
+
state.lastError = error;
|
|
202
|
+
discordLogger.error(`[GATEWAY] Shard ${shardId} error: ${formatErrorWithStack(error)}`);
|
|
203
|
+
});
|
|
204
|
+
discordClient.on(Events.ShardDisconnect, (event, shardId) => {
|
|
205
|
+
// ShardDisconnect fires for unrecoverable close codes (4004, 4010-4014).
|
|
206
|
+
// For recoverable codes discord.js fires ShardReconnecting instead.
|
|
207
|
+
const state = getOrCreateShardState(shardId);
|
|
208
|
+
state.lastDisconnectCode = event.code;
|
|
209
|
+
discordLogger.warn(`[GATEWAY] Shard ${shardId} disconnected: code=${event.code} (${describeCloseCode(event.code)})`);
|
|
210
|
+
});
|
|
211
|
+
discordClient.on(Events.ShardReconnecting, (shardId) => {
|
|
212
|
+
// discord.js strips the close code before emitting this event.
|
|
213
|
+
// We log whatever context we captured from preceding ShardError events.
|
|
214
|
+
const state = getOrCreateShardState(shardId);
|
|
215
|
+
state.attempts++;
|
|
216
|
+
const parts = [`attempt #${state.attempts}`];
|
|
217
|
+
if (state.lastDisconnectCode !== undefined) {
|
|
218
|
+
parts.push(`close code=${state.lastDisconnectCode} (${describeCloseCode(state.lastDisconnectCode)})`);
|
|
219
|
+
}
|
|
220
|
+
if (state.lastError) {
|
|
221
|
+
parts.push(`last error: ${state.lastError.message}`);
|
|
222
|
+
}
|
|
223
|
+
discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
|
|
224
|
+
});
|
|
225
|
+
discordClient.on(Events.ShardResume, (shardId, replayedEvents) => {
|
|
226
|
+
const state = shardReconnectState.get(shardId);
|
|
227
|
+
if (state?.attempts) {
|
|
228
|
+
discordLogger.log(`[GATEWAY] Shard ${shardId} resumed after ${state.attempts} reconnect attempt(s), ${replayedEvents} replayed events`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
discordLogger.log(`[GATEWAY] Shard ${shardId} resumed, ${replayedEvents} replayed events`);
|
|
232
|
+
}
|
|
233
|
+
shardReconnectState.delete(shardId);
|
|
234
|
+
});
|
|
235
|
+
// ShardReady fires when a shard completes a fresh IDENTIFY (not RESUME).
|
|
236
|
+
// After a gateway proxy redeploy, sessions are lost (in-memory), so RESUME
|
|
237
|
+
// fails with INVALID_SESSION and discord.js falls back to fresh IDENTIFY.
|
|
238
|
+
discordClient.on(Events.ShardReady, (shardId) => {
|
|
239
|
+
const state = shardReconnectState.get(shardId);
|
|
240
|
+
if (state?.attempts) {
|
|
241
|
+
discordLogger.log(`[GATEWAY] Shard ${shardId} ready after ${state.attempts} reconnect attempt(s)`);
|
|
242
|
+
}
|
|
243
|
+
shardReconnectState.delete(shardId);
|
|
244
|
+
});
|
|
245
|
+
discordClient.on(Events.Invalidated, () => {
|
|
246
|
+
discordLogger.error('[GATEWAY] Session invalidated by Discord');
|
|
247
|
+
});
|
|
248
|
+
discordClient.on(Events.MessageCreate, async (message) => {
|
|
249
|
+
try {
|
|
250
|
+
const isSelfBotMessage = Boolean(discordClient.user && message.author?.id === discordClient.user.id);
|
|
251
|
+
const promptMarker = parseEmbedFooterMarker({
|
|
252
|
+
footer: message.embeds[0]?.footer?.text,
|
|
253
|
+
});
|
|
254
|
+
const isCliInjectedPrompt = Boolean(isSelfBotMessage && isInjectedPromptMarker({ marker: promptMarker }));
|
|
255
|
+
const sessionStartSource = isCliInjectedPrompt
|
|
256
|
+
? parseSessionStartSourceFromMarker(promptMarker)
|
|
257
|
+
: undefined;
|
|
258
|
+
const cliInjectedUsername = isCliInjectedPrompt
|
|
259
|
+
? promptMarker?.username || 'kimaki-cli'
|
|
260
|
+
: undefined;
|
|
261
|
+
const cliInjectedUserId = isCliInjectedPrompt
|
|
262
|
+
? promptMarker?.userId
|
|
263
|
+
: undefined;
|
|
264
|
+
const cliInjectedAgent = isCliInjectedPrompt
|
|
265
|
+
? promptMarker?.agent
|
|
266
|
+
: undefined;
|
|
267
|
+
const cliInjectedModel = isCliInjectedPrompt
|
|
268
|
+
? promptMarker?.model
|
|
269
|
+
: undefined;
|
|
270
|
+
const cliInjectedPermissions = isCliInjectedPrompt
|
|
271
|
+
? promptMarker?.permissions
|
|
272
|
+
: undefined;
|
|
273
|
+
const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
|
|
274
|
+
? promptMarker?.injectionGuardPatterns
|
|
275
|
+
: undefined;
|
|
276
|
+
// Always ignore our own messages (unless CLI-injected prompt above).
|
|
277
|
+
// Without this, assigning the Kimaki role to the bot itself would loop.
|
|
278
|
+
if (isSelfBotMessage && !isCliInjectedPrompt) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// Allow CLI-injected prompts from this Kimaki bot through even when role
|
|
282
|
+
// reconciliation did not give the bot the "Kimaki" role yet. Other bots
|
|
283
|
+
// still need Kimaki permission so multi-agent orchestration stays opt-in.
|
|
284
|
+
const isInjectedSelfBotMessage = isCliInjectedPrompt && message.author?.id === discordClient.user?.id;
|
|
285
|
+
if (message.author?.bot && !isInjectedSelfBotMessage) {
|
|
286
|
+
if (!hasKimakiBotPermission(message.member)) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Ignore messages that start with a mention of another user (not the bot).
|
|
291
|
+
// These are likely users talking to each other, not the bot.
|
|
292
|
+
const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
|
|
293
|
+
if (leadingMentionMatch) {
|
|
294
|
+
const mentionedUserId = leadingMentionMatch[1];
|
|
295
|
+
if (mentionedUserId !== discordClient.user?.id) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (message.partial) {
|
|
300
|
+
discordLogger.log(`Fetching partial message ${message.id}`);
|
|
301
|
+
const fetched = await errore.tryAsync({
|
|
302
|
+
try: () => message.fetch(),
|
|
303
|
+
catch: (e) => e,
|
|
304
|
+
});
|
|
305
|
+
if (fetched instanceof Error) {
|
|
306
|
+
discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Check mention mode BEFORE permission check for text channels.
|
|
311
|
+
// When mention mode is enabled, users without Kimaki role can message
|
|
312
|
+
// without getting a permission error - we just silently ignore.
|
|
313
|
+
const channel = message.channel;
|
|
314
|
+
if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
|
|
315
|
+
const textChannel = channel;
|
|
316
|
+
const mentionModeEnabled = await getChannelMentionMode(textChannel.id);
|
|
317
|
+
if (mentionModeEnabled) {
|
|
318
|
+
const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
|
|
319
|
+
const isShellCommand = message.content?.startsWith('!');
|
|
320
|
+
if (!botMentioned && !isShellCommand) {
|
|
321
|
+
voiceLogger.log(`[IGNORED] Mention mode enabled, bot not mentioned`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!isCliInjectedPrompt && message.guild && message.member) {
|
|
327
|
+
if (hasNoKimakiRole(message.member)) {
|
|
328
|
+
await message.reply({
|
|
329
|
+
content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
|
|
330
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (!hasKimakiBotPermission(message.member)) {
|
|
335
|
+
await message.reply({
|
|
336
|
+
content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
337
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
338
|
+
});
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const isThread = [
|
|
343
|
+
ChannelType.PublicThread,
|
|
344
|
+
ChannelType.PrivateThread,
|
|
345
|
+
ChannelType.AnnouncementThread,
|
|
346
|
+
].includes(channel.type);
|
|
347
|
+
if (isThread) {
|
|
348
|
+
const thread = channel;
|
|
349
|
+
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
|
|
350
|
+
// Only respond in threads kimaki knows about (has a session row in DB),
|
|
351
|
+
// where the bot is explicitly @mentioned, or where the bot created the
|
|
352
|
+
// thread itself (e.g. /new-worktree, /fork, kimaki send). This prevents
|
|
353
|
+
// the bot from hijacking user-created threads in project channels while
|
|
354
|
+
// still responding to bot-created threads that may not yet have a session
|
|
355
|
+
// row with a non-empty session_id (createPendingWorktree sets ''). (GitHub #84)
|
|
356
|
+
const hasExistingSession = await getThreadSession(thread.id);
|
|
357
|
+
const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
|
|
358
|
+
const botCreatedThread = discordClient.user && thread.ownerId === discordClient.user.id;
|
|
359
|
+
if (!hasExistingSession &&
|
|
360
|
+
!botMentioned &&
|
|
361
|
+
!isCliInjectedPrompt &&
|
|
362
|
+
!botCreatedThread) {
|
|
363
|
+
discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const parent = thread.parent;
|
|
367
|
+
let projectDirectory;
|
|
368
|
+
if (parent) {
|
|
369
|
+
const channelConfig = await getChannelDirectory(parent.id);
|
|
370
|
+
if (channelConfig) {
|
|
371
|
+
projectDirectory = channelConfig.directory;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Check if this thread is a worktree thread.
|
|
375
|
+
// When the runtime exists in memory, pending worktrees are handled by
|
|
376
|
+
// the preprocess chain (messages queue behind the worktree promise).
|
|
377
|
+
// After a bot restart the runtime is gone, so we must reject messages
|
|
378
|
+
// for pending worktrees to avoid running in the base directory.
|
|
379
|
+
const worktreeInfo = await getThreadWorktree(thread.id);
|
|
380
|
+
if (worktreeInfo) {
|
|
381
|
+
if (worktreeInfo.status === 'pending' && !getRuntime(thread.id)) {
|
|
382
|
+
await message.reply({
|
|
383
|
+
content: '⏳ Worktree is still being created. Please wait...',
|
|
384
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
385
|
+
});
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (worktreeInfo.status === 'error') {
|
|
389
|
+
await message.reply({
|
|
390
|
+
content: `❌ Worktree creation failed: ${(worktreeInfo.error_message || '').slice(0, 1900)}`,
|
|
391
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
392
|
+
});
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// Use original project directory for OpenCode server (session lives there)
|
|
396
|
+
// The worktree directory is passed via query.directory in prompt/command calls
|
|
397
|
+
if (worktreeInfo.project_directory) {
|
|
398
|
+
projectDirectory = worktreeInfo.project_directory;
|
|
399
|
+
discordLogger.log(`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.worktree_directory})`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (projectDirectory && !fs.existsSync(projectDirectory)) {
|
|
403
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`);
|
|
404
|
+
await message.reply({
|
|
405
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
|
|
406
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
// ! prefix runs a shell command instead of starting/continuing a session.
|
|
411
|
+
// Use worktree directory if available, so commands run in the worktree cwd.
|
|
412
|
+
// Skip shell commands while worktree is pending — they'd run in the base dir.
|
|
413
|
+
if (message.content?.startsWith('!') &&
|
|
414
|
+
projectDirectory &&
|
|
415
|
+
worktreeInfo?.status !== 'pending') {
|
|
416
|
+
const shellCmd = message.content.slice(1).trim();
|
|
417
|
+
if (shellCmd) {
|
|
418
|
+
const shellDir = worktreeInfo?.status === 'ready' &&
|
|
419
|
+
worktreeInfo.worktree_directory
|
|
420
|
+
? worktreeInfo.worktree_directory
|
|
421
|
+
: projectDirectory;
|
|
422
|
+
const loadingReply = await message.reply({
|
|
423
|
+
content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
|
|
424
|
+
});
|
|
425
|
+
const result = await runShellCommand({
|
|
426
|
+
command: shellCmd,
|
|
427
|
+
directory: shellDir,
|
|
428
|
+
});
|
|
429
|
+
await loadingReply.edit({ content: result });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const hasVoiceAttachment = message.attachments.some((attachment) => {
|
|
434
|
+
return isVoiceAttachment(attachment);
|
|
435
|
+
});
|
|
436
|
+
if (!projectDirectory) {
|
|
437
|
+
discordLogger.log(`Cannot process message: no project directory for thread ${thread.id}`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const resolvedProjectDir = projectDirectory;
|
|
441
|
+
const sdkDir = worktreeInfo?.status === 'ready' &&
|
|
442
|
+
worktreeInfo.worktree_directory
|
|
443
|
+
? worktreeInfo.worktree_directory
|
|
444
|
+
: resolvedProjectDir;
|
|
445
|
+
const runtime = getOrCreateRuntime({
|
|
446
|
+
threadId: thread.id,
|
|
447
|
+
thread,
|
|
448
|
+
projectDirectory: resolvedProjectDir,
|
|
449
|
+
sdkDirectory: sdkDir,
|
|
450
|
+
channelId: parent?.id || undefined,
|
|
451
|
+
appId: currentAppId,
|
|
452
|
+
});
|
|
453
|
+
// Cancel interactive UI when a real user sends a message.
|
|
454
|
+
if (!message.author.bot && !isCliInjectedPrompt) {
|
|
455
|
+
cancelPendingActionButtons(thread.id);
|
|
456
|
+
cancelHtmlActionsForThread(thread.id);
|
|
457
|
+
const dismissedPermission = await cancelPendingPermission(thread.id);
|
|
458
|
+
if (dismissedPermission) {
|
|
459
|
+
await runtime.abortActiveRunAndWait({
|
|
460
|
+
reason: 'user sent a new message while permission was pending',
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const dismissedQuestion = hasPendingQuestionForThread(thread.id);
|
|
464
|
+
if (dismissedQuestion) {
|
|
465
|
+
await cancelPendingQuestion(thread.id);
|
|
466
|
+
await runtime.abortActiveRunAndWait({
|
|
467
|
+
reason: 'user sent a new message while question was pending',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
void cancelPendingFileUpload(thread.id);
|
|
471
|
+
}
|
|
472
|
+
// Expensive pre-processing (voice transcription, context fetch,
|
|
473
|
+
// attachment download) runs inside the runtime's serialized
|
|
474
|
+
// preprocess chain, preserving Discord arrival order without
|
|
475
|
+
// blocking SSE event handling in dispatchAction.
|
|
476
|
+
const enqueueResult = await runtime.enqueueIncoming({
|
|
477
|
+
prompt: '',
|
|
478
|
+
userId: cliInjectedUserId || message.author.id,
|
|
479
|
+
username: cliInjectedUsername ||
|
|
480
|
+
message.member?.displayName ||
|
|
481
|
+
message.author.displayName,
|
|
482
|
+
sourceMessageId: message.id,
|
|
483
|
+
sourceThreadId: thread.id,
|
|
484
|
+
appId: currentAppId,
|
|
485
|
+
agent: cliInjectedAgent,
|
|
486
|
+
model: cliInjectedModel,
|
|
487
|
+
permissions: cliInjectedPermissions,
|
|
488
|
+
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
489
|
+
sessionStartSource: sessionStartSource
|
|
490
|
+
? {
|
|
491
|
+
scheduleKind: sessionStartSource.scheduleKind,
|
|
492
|
+
scheduledTaskId: sessionStartSource.scheduledTaskId,
|
|
493
|
+
}
|
|
494
|
+
: undefined,
|
|
495
|
+
preprocess: () => {
|
|
496
|
+
return preprocessExistingThreadMessage({
|
|
497
|
+
message,
|
|
498
|
+
thread,
|
|
499
|
+
projectDirectory: resolvedProjectDir,
|
|
500
|
+
channelId: parent?.id || undefined,
|
|
501
|
+
isCliInjected: isCliInjectedPrompt,
|
|
502
|
+
hasVoiceAttachment,
|
|
503
|
+
appId: currentAppId,
|
|
504
|
+
});
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
// Notify when a voice message was queued instead of sent immediately
|
|
508
|
+
if (enqueueResult.queued && enqueueResult.position) {
|
|
509
|
+
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (channel.type === ChannelType.GuildText) {
|
|
513
|
+
// `kimaki send` posts a starter message with a `start` embed marker,
|
|
514
|
+
// then creates the thread via REST. The ThreadCreate handler picks up
|
|
515
|
+
// that thread and starts the session. If we don't skip here, this
|
|
516
|
+
// handler races the CLI to call startThread() on the same message,
|
|
517
|
+
// causing DiscordAPIError[160004] "A thread has already been created
|
|
518
|
+
// for this message".
|
|
519
|
+
if (promptMarker?.start) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const textChannel = channel;
|
|
523
|
+
voiceLogger.log(`[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`);
|
|
524
|
+
const channelConfig = await getChannelDirectory(textChannel.id);
|
|
525
|
+
if (!channelConfig) {
|
|
526
|
+
const botMentioned = Boolean(discordClient.user && message.mentions.has(discordClient.user.id));
|
|
527
|
+
if (botMentioned) {
|
|
528
|
+
// TODO: Consider creating/using a session for any text channel when Kimaki is
|
|
529
|
+
// explicitly @mentioned, so the bot can answer quick questions even before
|
|
530
|
+
// the channel is linked to a project.
|
|
531
|
+
await message.reply({
|
|
532
|
+
content: 'This channel is not connected to an OpenCode project.\nSend your message in a project channel, or use `/add-project` for an existing project, or `/create-new-project` to make a new one.',
|
|
533
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
534
|
+
});
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no project directory configured`);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const projectDirectory = channelConfig.directory;
|
|
541
|
+
// Note: Mention mode is checked early in the handler (before permission check)
|
|
542
|
+
// to avoid sending permission errors to users who just didn't @mention the bot.
|
|
543
|
+
discordLogger.log(`DIRECTORY: Found kimaki.directory: ${projectDirectory}`);
|
|
544
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
545
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`);
|
|
546
|
+
await message.reply({
|
|
547
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
|
|
548
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
549
|
+
});
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// ! prefix runs a shell command instead of starting a session
|
|
553
|
+
if (message.content?.startsWith('!')) {
|
|
554
|
+
const shellCmd = message.content.slice(1).trim();
|
|
555
|
+
if (shellCmd) {
|
|
556
|
+
const loadingReply = await message.reply({
|
|
557
|
+
content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
|
|
558
|
+
});
|
|
559
|
+
const result = await runShellCommand({
|
|
560
|
+
command: shellCmd,
|
|
561
|
+
directory: projectDirectory,
|
|
562
|
+
});
|
|
563
|
+
await loadingReply.edit({ content: result });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const hasVoice = message.attachments.some((attachment) => {
|
|
568
|
+
return isVoiceAttachment(attachment);
|
|
569
|
+
});
|
|
570
|
+
const baseThreadName = hasVoice
|
|
571
|
+
? 'Voice Message'
|
|
572
|
+
: stripMentions(message.content || '')
|
|
573
|
+
.replace(/\s+/g, ' ')
|
|
574
|
+
.trim() || 'kimaki thread';
|
|
575
|
+
// Check if worktrees should be enabled (CLI flag OR channel setting)
|
|
576
|
+
const shouldUseWorktrees = useWorktrees || (await getChannelWorktreesEnabled(textChannel.id));
|
|
577
|
+
// Add worktree prefix if worktrees are enabled
|
|
578
|
+
const threadName = shouldUseWorktrees
|
|
579
|
+
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
580
|
+
: baseThreadName;
|
|
581
|
+
const thread = await message.startThread({
|
|
582
|
+
name: threadName.slice(0, 80),
|
|
583
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
584
|
+
reason: 'Start Claude session',
|
|
585
|
+
});
|
|
586
|
+
// Add user to thread so it appears in their sidebar
|
|
587
|
+
await thread.members.add(message.author.id);
|
|
588
|
+
discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
|
|
589
|
+
// Create runtime immediately so follow-up messages queue naturally
|
|
590
|
+
// via the preprocess chain instead of being rejected with "please wait".
|
|
591
|
+
// When worktrees are enabled, the worktree promise runs concurrently
|
|
592
|
+
// and the first message's preprocess callback awaits it before resolving.
|
|
593
|
+
let worktreePromise;
|
|
594
|
+
if (shouldUseWorktrees) {
|
|
595
|
+
const worktreeName = formatWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
|
|
596
|
+
discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
|
|
597
|
+
const worktreeStatusMessage = await thread
|
|
598
|
+
.send({
|
|
599
|
+
content: worktreeCreatingMessage(worktreeName),
|
|
600
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
601
|
+
})
|
|
602
|
+
.catch(() => undefined);
|
|
603
|
+
worktreePromise = createWorktreeInBackground({
|
|
604
|
+
thread,
|
|
605
|
+
starterMessage: worktreeStatusMessage,
|
|
606
|
+
worktreeName,
|
|
607
|
+
projectDirectory,
|
|
608
|
+
rest: discordClient.rest,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
const channelRuntime = getOrCreateRuntime({
|
|
612
|
+
threadId: thread.id,
|
|
613
|
+
thread,
|
|
614
|
+
projectDirectory,
|
|
615
|
+
sdkDirectory: projectDirectory,
|
|
616
|
+
channelId: textChannel.id,
|
|
617
|
+
appId: currentAppId,
|
|
618
|
+
});
|
|
619
|
+
await channelRuntime.enqueueIncoming({
|
|
620
|
+
prompt: '',
|
|
621
|
+
userId: message.author.id,
|
|
622
|
+
username: message.member?.displayName || message.author.displayName,
|
|
623
|
+
sourceMessageId: message.id,
|
|
624
|
+
sourceThreadId: thread.id,
|
|
625
|
+
appId: currentAppId,
|
|
626
|
+
preprocess: async () => {
|
|
627
|
+
// Wait for worktree creation + install before preprocessing.
|
|
628
|
+
// Follow-up messages queue behind this in the preprocess chain.
|
|
629
|
+
let sessionDirectory = projectDirectory;
|
|
630
|
+
if (worktreePromise) {
|
|
631
|
+
const result = await worktreePromise;
|
|
632
|
+
if (!(result instanceof Error)) {
|
|
633
|
+
sessionDirectory = result;
|
|
634
|
+
channelRuntime.handleDirectoryChanged({
|
|
635
|
+
oldDirectory: projectDirectory,
|
|
636
|
+
newDirectory: sessionDirectory,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return preprocessNewThreadMessage({
|
|
641
|
+
message,
|
|
642
|
+
thread,
|
|
643
|
+
projectDirectory: sessionDirectory,
|
|
644
|
+
hasVoiceAttachment: hasVoice,
|
|
645
|
+
appId: currentAppId,
|
|
646
|
+
});
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
// discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
voiceLogger.error('Discord handler error:', error);
|
|
656
|
+
void notifyError(error, 'MessageCreate handler error');
|
|
657
|
+
try {
|
|
658
|
+
const errMsg = (error instanceof Error ? error.message : String(error)).slice(0, 1900);
|
|
659
|
+
await message.reply({
|
|
660
|
+
content: `Error: ${errMsg}`,
|
|
661
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
catch (sendError) {
|
|
665
|
+
voiceLogger.error('Discord handler error (fallback):', sendError instanceof Error ? sendError.message : String(sendError));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
670
|
+
// Uses JSON embed marker to pass options (start, worktree name)
|
|
671
|
+
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
672
|
+
try {
|
|
673
|
+
if (!newlyCreated) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
// Only handle threads in text channels
|
|
677
|
+
const parent = thread.parent;
|
|
678
|
+
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
// Get the starter message to check for auto-start marker
|
|
682
|
+
const starterMessage = await thread
|
|
683
|
+
.fetchStarterMessage()
|
|
684
|
+
.catch((error) => {
|
|
685
|
+
discordLogger.warn(`[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`, error instanceof Error ? error.stack : String(error));
|
|
686
|
+
return null;
|
|
687
|
+
});
|
|
688
|
+
if (!starterMessage) {
|
|
689
|
+
discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
// Parse JSON marker from embed footer
|
|
693
|
+
const embedFooter = starterMessage.embeds[0]?.footer?.text;
|
|
694
|
+
if (!embedFooter) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
// Only process markers from our own bot messages to prevent crafted embeds
|
|
698
|
+
if (starterMessage.author?.id !== discordClient.user?.id) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const marker = parseEmbedFooterMarker({
|
|
702
|
+
footer: embedFooter,
|
|
703
|
+
});
|
|
704
|
+
if (!marker) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (!marker.start) {
|
|
708
|
+
return; // Not an auto-start thread
|
|
709
|
+
}
|
|
710
|
+
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
|
|
711
|
+
const textAttachmentsContent = await getTextAttachments(starterMessage);
|
|
712
|
+
const messageText = resolveMentions(starterMessage).trim();
|
|
713
|
+
const prompt = textAttachmentsContent
|
|
714
|
+
? `${messageText}\n\n${textAttachmentsContent}`
|
|
715
|
+
: messageText;
|
|
716
|
+
if (!prompt) {
|
|
717
|
+
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// Get directory from database
|
|
721
|
+
const channelConfig = await getChannelDirectory(parent.id);
|
|
722
|
+
if (!channelConfig) {
|
|
723
|
+
discordLogger.log(`[BOT_SESSION] No project directory configured for parent channel`);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const projectDirectory = channelConfig.directory;
|
|
727
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
728
|
+
discordLogger.error(`[BOT_SESSION] Directory does not exist: ${projectDirectory}`);
|
|
729
|
+
await thread.send({
|
|
730
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
|
|
731
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
732
|
+
});
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
// Start worktree creation concurrently if requested.
|
|
736
|
+
// The runtime is created immediately so follow-up messages queue
|
|
737
|
+
// naturally; the worktree promise is awaited inside enqueueIncoming.
|
|
738
|
+
let worktreePromise;
|
|
739
|
+
if (marker.worktree) {
|
|
740
|
+
discordLogger.log(`[BOT_SESSION] Creating worktree: ${marker.worktree}`);
|
|
741
|
+
const worktreeStatusMessage = await thread
|
|
742
|
+
.send({
|
|
743
|
+
content: worktreeCreatingMessage(marker.worktree),
|
|
744
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
745
|
+
})
|
|
746
|
+
.catch(() => undefined);
|
|
747
|
+
worktreePromise = createWorktreeInBackground({
|
|
748
|
+
thread,
|
|
749
|
+
starterMessage: worktreeStatusMessage,
|
|
750
|
+
worktreeName: marker.worktree,
|
|
751
|
+
projectDirectory,
|
|
752
|
+
rest: discordClient.rest,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
// --cwd: reuse an existing worktree directory. Revalidate at bot-time
|
|
756
|
+
// (CLI validated at send-time but the path could become stale).
|
|
757
|
+
// Store in thread_worktrees as ready with origin=external so
|
|
758
|
+
// destructive actions (merge, delete) are gated.
|
|
759
|
+
// --cwd: if it matches projectDirectory, ignore silently (already the default).
|
|
760
|
+
// Otherwise revalidate as a git worktree and store with origin=external.
|
|
761
|
+
let cwdDirectory;
|
|
762
|
+
if (marker.cwd) {
|
|
763
|
+
const cwdResult = await validateWorktreeDirectory({
|
|
764
|
+
projectDirectory,
|
|
765
|
+
candidatePath: marker.cwd,
|
|
766
|
+
});
|
|
767
|
+
if (cwdResult instanceof Error) {
|
|
768
|
+
discordLogger.error(`[BOT_SESSION] --cwd validation failed: ${cwdResult.message}`);
|
|
769
|
+
await thread.send({
|
|
770
|
+
content: `✗ --cwd validation failed: ${cwdResult.message.slice(0, 1900)}`,
|
|
771
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
772
|
+
});
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
// If cwd is the same as projectDirectory, skip worktree setup entirely
|
|
776
|
+
if (path.resolve(cwdResult) !== path.resolve(projectDirectory)) {
|
|
777
|
+
cwdDirectory = cwdResult;
|
|
778
|
+
// Resolve actual branch name instead of using directory basename
|
|
779
|
+
const branchResult = await git(cwdDirectory, 'symbolic-ref --short HEAD');
|
|
780
|
+
const cwdWorktreeName = branchResult instanceof Error
|
|
781
|
+
? path.basename(cwdDirectory)
|
|
782
|
+
: branchResult;
|
|
783
|
+
await createPendingWorktree({
|
|
784
|
+
threadId: thread.id,
|
|
785
|
+
worktreeName: cwdWorktreeName,
|
|
786
|
+
projectDirectory,
|
|
787
|
+
});
|
|
788
|
+
await setWorktreeReady({
|
|
789
|
+
threadId: thread.id,
|
|
790
|
+
worktreeDirectory: cwdDirectory,
|
|
791
|
+
});
|
|
792
|
+
// React with tree emoji to mark as worktree thread
|
|
793
|
+
await reactToThread({
|
|
794
|
+
rest: discordClient.rest,
|
|
795
|
+
threadId: thread.id,
|
|
796
|
+
channelId: parent.id,
|
|
797
|
+
emoji: '🌳',
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
|
|
802
|
+
const botThreadStartSource = parseSessionStartSourceFromMarker(marker);
|
|
803
|
+
const runtime = getOrCreateRuntime({
|
|
804
|
+
threadId: thread.id,
|
|
805
|
+
thread,
|
|
806
|
+
projectDirectory,
|
|
807
|
+
sdkDirectory: projectDirectory,
|
|
808
|
+
channelId: parent.id,
|
|
809
|
+
appId: currentAppId,
|
|
810
|
+
});
|
|
811
|
+
await runtime.enqueueIncoming({
|
|
812
|
+
prompt: '',
|
|
813
|
+
userId: marker.userId || '',
|
|
814
|
+
username: marker.username || 'bot',
|
|
815
|
+
appId: currentAppId,
|
|
816
|
+
agent: marker.agent,
|
|
817
|
+
model: marker.model,
|
|
818
|
+
permissions: marker.permissions,
|
|
819
|
+
injectionGuardPatterns: marker.injectionGuardPatterns,
|
|
820
|
+
mode: 'opencode',
|
|
821
|
+
sessionStartSource: botThreadStartSource
|
|
822
|
+
? {
|
|
823
|
+
scheduleKind: botThreadStartSource.scheduleKind,
|
|
824
|
+
scheduledTaskId: botThreadStartSource.scheduledTaskId,
|
|
825
|
+
}
|
|
826
|
+
: undefined,
|
|
827
|
+
preprocess: async () => {
|
|
828
|
+
// Wait for worktree creation + install before starting session.
|
|
829
|
+
if (worktreePromise) {
|
|
830
|
+
const result = await worktreePromise;
|
|
831
|
+
if (!(result instanceof Error)) {
|
|
832
|
+
runtime.handleDirectoryChanged({
|
|
833
|
+
oldDirectory: projectDirectory,
|
|
834
|
+
newDirectory: result,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// --cwd: switch sdkDirectory to the existing worktree path
|
|
839
|
+
if (cwdDirectory) {
|
|
840
|
+
runtime.handleDirectoryChanged({
|
|
841
|
+
oldDirectory: projectDirectory,
|
|
842
|
+
newDirectory: cwdDirectory,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
return { prompt, mode: 'opencode' };
|
|
846
|
+
},
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
voiceLogger.error('[BOT_SESSION] Error handling bot-initiated thread:', error);
|
|
851
|
+
void notifyError(error, 'ThreadCreate handler error');
|
|
852
|
+
try {
|
|
853
|
+
const errMsg = (error instanceof Error ? error.message : String(error)).slice(0, 1900);
|
|
854
|
+
await thread.send({
|
|
855
|
+
content: `Error: ${errMsg}`,
|
|
856
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
catch (sendError) {
|
|
860
|
+
voiceLogger.error('[BOT_SESSION] Failed to send error message:', sendError instanceof Error ? sendError.message : String(sendError));
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
// Dispose runtime when a thread is deleted so memory is freed immediately
|
|
865
|
+
// instead of waiting for the idle sweeper (1 hour default).
|
|
866
|
+
discordClient.on(Events.ThreadDelete, (thread) => {
|
|
867
|
+
disposeRuntime(thread.id);
|
|
868
|
+
});
|
|
869
|
+
// Clean up SQLite when a Discord channel is deleted so project list
|
|
870
|
+
// doesn't show stale ghost entries. Thread runtimes inside the deleted
|
|
871
|
+
// channel are disposed by their own ThreadDelete events from Discord.
|
|
872
|
+
discordClient.on(Events.ChannelDelete, async (channel) => {
|
|
873
|
+
try {
|
|
874
|
+
const deleted = await deleteChannelDirectoryById(channel.id);
|
|
875
|
+
if (deleted) {
|
|
876
|
+
discordLogger.log(`Cleaned up channel_directories for deleted channel ${channel.id}`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
notifyError(error instanceof Error ? error : new Error(String(error)), `Failed to clean up channel_directories for deleted channel ${channel.id}`);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
// Skip login if the caller already connected the client (e.g. cli.ts logs in
|
|
884
|
+
// before calling startDiscordBot). Calling login() again destroys the existing
|
|
885
|
+
// WebSocket (close code 1000) and triggers a spurious ShardReconnecting event.
|
|
886
|
+
if (!discordClient.isReady()) {
|
|
887
|
+
await discordClient.login(token);
|
|
888
|
+
}
|
|
889
|
+
startHeapMonitor();
|
|
890
|
+
const stopTaskRunner = startTaskRunner({ token });
|
|
891
|
+
const stopRuntimeIdleSweeper = startRuntimeIdleSweeper();
|
|
892
|
+
const handleShutdown = async (signal, { skipExit = false } = {}) => {
|
|
893
|
+
discordLogger.log(`Received ${signal}, cleaning up...`);
|
|
894
|
+
if (global.shuttingDown) {
|
|
895
|
+
discordLogger.log('Already shutting down, ignoring duplicate signal');
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
;
|
|
899
|
+
global.shuttingDown = true;
|
|
900
|
+
try {
|
|
901
|
+
await stopRuntimeIdleSweeper();
|
|
902
|
+
await stopTaskRunner();
|
|
903
|
+
await flushDebouncedProcessCallbacks().catch((error) => {
|
|
904
|
+
discordLogger.warn('Failed to flush debounced process callbacks:', error instanceof Error ? error.stack : String(error));
|
|
905
|
+
});
|
|
906
|
+
// Cancel pending IPC requests so plugin tools don't hang
|
|
907
|
+
await cancelAllPendingIpcRequests().catch((e) => {
|
|
908
|
+
discordLogger.warn('Failed to cancel pending IPC requests:', e.message);
|
|
909
|
+
});
|
|
910
|
+
const cleanupPromises = [];
|
|
911
|
+
for (const [guildId] of voiceConnections) {
|
|
912
|
+
voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
|
|
913
|
+
cleanupPromises.push(cleanupVoiceConnection(guildId));
|
|
914
|
+
}
|
|
915
|
+
if (cleanupPromises.length > 0) {
|
|
916
|
+
voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
|
|
917
|
+
await Promise.allSettled(cleanupPromises);
|
|
918
|
+
discordLogger.log(`All voice connections cleaned up`);
|
|
919
|
+
}
|
|
920
|
+
voiceLogger.log('[SHUTDOWN] Stopping OpenCode server');
|
|
921
|
+
stopExternalOpencodeSessionSync();
|
|
922
|
+
await stopOpencodeServer();
|
|
923
|
+
discordLogger.log('Closing database...');
|
|
924
|
+
await closeDatabase();
|
|
925
|
+
discordLogger.log('Stopping hrana server...');
|
|
926
|
+
await stopHranaServer();
|
|
927
|
+
discordLogger.log('Destroying Discord client...');
|
|
928
|
+
discordClient.destroy();
|
|
929
|
+
discordLogger.log('Cleanup complete.');
|
|
930
|
+
if (!skipExit) {
|
|
931
|
+
process.exit(0);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
|
|
936
|
+
if (!skipExit) {
|
|
937
|
+
process.exit(1);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
process.on('SIGTERM', async () => {
|
|
942
|
+
try {
|
|
943
|
+
await handleShutdown('SIGTERM');
|
|
944
|
+
}
|
|
945
|
+
catch (error) {
|
|
946
|
+
voiceLogger.error('[SIGTERM] Error during shutdown:', error);
|
|
947
|
+
process.exit(1);
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
process.on('SIGINT', async () => {
|
|
951
|
+
try {
|
|
952
|
+
await handleShutdown('SIGINT');
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
voiceLogger.error('[SIGINT] Error during shutdown:', error);
|
|
956
|
+
process.exit(1);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
process.on('SIGUSR1', () => {
|
|
960
|
+
discordLogger.log('Received SIGUSR1, writing heap snapshot...');
|
|
961
|
+
writeHeapSnapshot().catch((e) => {
|
|
962
|
+
discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
process.on('SIGUSR2', async () => {
|
|
966
|
+
discordLogger.log('Received SIGUSR2, restarting after cleanup...');
|
|
967
|
+
try {
|
|
968
|
+
await handleShutdown('SIGUSR2', { skipExit: true });
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
voiceLogger.error('[SIGUSR2] Error during shutdown:', error);
|
|
972
|
+
}
|
|
973
|
+
const { spawn } = await import('node:child_process');
|
|
974
|
+
// Strip __KIMAKI_CHILD so the new process goes through the respawn wrapper in bin.js.
|
|
975
|
+
// V8 heap flags are already in process.execArgv from the initial spawn, and bin.ts
|
|
976
|
+
// will re-inject them if missing, so no need to add them here.
|
|
977
|
+
const env = { ...process.env };
|
|
978
|
+
delete env.__KIMAKI_CHILD;
|
|
979
|
+
spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
|
|
980
|
+
stdio: 'inherit',
|
|
981
|
+
detached: true,
|
|
982
|
+
cwd: process.cwd(),
|
|
983
|
+
env,
|
|
984
|
+
}).unref();
|
|
985
|
+
process.exit(0);
|
|
986
|
+
});
|
|
987
|
+
process.on('uncaughtException', (error) => {
|
|
988
|
+
discordLogger.error('Uncaught exception:', formatErrorWithStack(error));
|
|
989
|
+
notifyError(error, 'Uncaught exception in bot process');
|
|
990
|
+
void handleShutdown('uncaughtException', { skipExit: true }).catch((shutdownError) => {
|
|
991
|
+
discordLogger.error('[uncaughtException] shutdown failed:', formatErrorWithStack(shutdownError));
|
|
992
|
+
});
|
|
993
|
+
setTimeout(() => {
|
|
994
|
+
process.exit(1);
|
|
995
|
+
}, 250).unref();
|
|
996
|
+
});
|
|
997
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
998
|
+
if (global.shuttingDown) {
|
|
999
|
+
discordLogger.log('Ignoring unhandled rejection during shutdown:', reason);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
discordLogger.error('Unhandled rejection:', formatErrorWithStack(reason), 'at promise:', promise);
|
|
1003
|
+
const error = reason instanceof Error
|
|
1004
|
+
? reason
|
|
1005
|
+
: new Error(formatErrorWithStack(reason));
|
|
1006
|
+
void notifyError(error, 'Unhandled rejection in bot process');
|
|
1007
|
+
});
|
|
1008
|
+
}
|