@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.
Files changed (483) hide show
  1. package/bin.js +2 -0
  2. package/dist/agent-model.e2e.test.js +755 -0
  3. package/dist/ai-tool-to-genai.js +233 -0
  4. package/dist/ai-tool-to-genai.test.js +267 -0
  5. package/dist/ai-tool.js +6 -0
  6. package/dist/anthropic-auth-plugin.js +728 -0
  7. package/dist/anthropic-auth-plugin.test.js +125 -0
  8. package/dist/anthropic-auth-state.js +231 -0
  9. package/dist/bin.js +90 -0
  10. package/dist/channel-management.js +227 -0
  11. package/dist/cli-parsing.test.js +137 -0
  12. package/dist/cli-send-thread.e2e.test.js +356 -0
  13. package/dist/cli.js +3276 -0
  14. package/dist/commands/abort.js +65 -0
  15. package/dist/commands/action-buttons.js +245 -0
  16. package/dist/commands/add-project.js +113 -0
  17. package/dist/commands/agent.js +335 -0
  18. package/dist/commands/ask-question.js +274 -0
  19. package/dist/commands/btw.js +116 -0
  20. package/dist/commands/compact.js +120 -0
  21. package/dist/commands/context-usage.js +140 -0
  22. package/dist/commands/create-new-project.js +130 -0
  23. package/dist/commands/diff.js +63 -0
  24. package/dist/commands/file-upload.js +275 -0
  25. package/dist/commands/fork.js +220 -0
  26. package/dist/commands/gemini-apikey.js +70 -0
  27. package/dist/commands/login.js +885 -0
  28. package/dist/commands/mcp.js +239 -0
  29. package/dist/commands/memory-snapshot.js +24 -0
  30. package/dist/commands/mention-mode.js +44 -0
  31. package/dist/commands/merge-worktree.js +159 -0
  32. package/dist/commands/model-variant.js +364 -0
  33. package/dist/commands/model.js +776 -0
  34. package/dist/commands/new-worktree.js +366 -0
  35. package/dist/commands/paginated-select.js +57 -0
  36. package/dist/commands/permissions.js +274 -0
  37. package/dist/commands/queue.js +206 -0
  38. package/dist/commands/remove-project.js +115 -0
  39. package/dist/commands/restart-opencode-server.js +127 -0
  40. package/dist/commands/resume.js +149 -0
  41. package/dist/commands/run-command.js +79 -0
  42. package/dist/commands/screenshare.js +303 -0
  43. package/dist/commands/screenshare.test.js +20 -0
  44. package/dist/commands/session-id.js +78 -0
  45. package/dist/commands/session.js +176 -0
  46. package/dist/commands/share.js +80 -0
  47. package/dist/commands/tasks.js +205 -0
  48. package/dist/commands/types.js +2 -0
  49. package/dist/commands/undo-redo.js +305 -0
  50. package/dist/commands/unset-model.js +138 -0
  51. package/dist/commands/upgrade.js +42 -0
  52. package/dist/commands/user-command.js +155 -0
  53. package/dist/commands/verbosity.js +125 -0
  54. package/dist/commands/worktree-settings.js +43 -0
  55. package/dist/commands/worktrees.js +410 -0
  56. package/dist/condense-memory.js +33 -0
  57. package/dist/config.js +94 -0
  58. package/dist/context-awareness-plugin.js +363 -0
  59. package/dist/context-awareness-plugin.test.js +124 -0
  60. package/dist/critique-utils.js +95 -0
  61. package/dist/database.js +1310 -0
  62. package/dist/db.js +251 -0
  63. package/dist/db.test.js +138 -0
  64. package/dist/debounce-timeout.js +28 -0
  65. package/dist/debounced-process-flush.js +77 -0
  66. package/dist/discord-bot.js +1008 -0
  67. package/dist/discord-command-registration.js +524 -0
  68. package/dist/discord-urls.js +81 -0
  69. package/dist/discord-utils.js +591 -0
  70. package/dist/discord-utils.test.js +134 -0
  71. package/dist/errors.js +157 -0
  72. package/dist/escape-backticks.test.js +429 -0
  73. package/dist/event-stream-real-capture.e2e.test.js +533 -0
  74. package/dist/eventsource-parser.test.js +327 -0
  75. package/dist/exec-async.js +26 -0
  76. package/dist/external-opencode-sync.js +480 -0
  77. package/dist/format-tables.js +302 -0
  78. package/dist/format-tables.test.js +308 -0
  79. package/dist/forum-sync/config.js +79 -0
  80. package/dist/forum-sync/discord-operations.js +154 -0
  81. package/dist/forum-sync/index.js +5 -0
  82. package/dist/forum-sync/markdown.js +113 -0
  83. package/dist/forum-sync/sync-to-discord.js +417 -0
  84. package/dist/forum-sync/sync-to-files.js +190 -0
  85. package/dist/forum-sync/types.js +53 -0
  86. package/dist/forum-sync/watchers.js +307 -0
  87. package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
  88. package/dist/gateway-proxy.e2e.test.js +483 -0
  89. package/dist/genai-worker-wrapper.js +111 -0
  90. package/dist/genai-worker.js +311 -0
  91. package/dist/genai.js +232 -0
  92. package/dist/generated/browser.js +17 -0
  93. package/dist/generated/client.js +37 -0
  94. package/dist/generated/commonInputTypes.js +10 -0
  95. package/dist/generated/enums.js +52 -0
  96. package/dist/generated/internal/class.js +49 -0
  97. package/dist/generated/internal/prismaNamespace.js +253 -0
  98. package/dist/generated/internal/prismaNamespaceBrowser.js +223 -0
  99. package/dist/generated/models/bot_api_keys.js +1 -0
  100. package/dist/generated/models/bot_tokens.js +1 -0
  101. package/dist/generated/models/channel_agents.js +1 -0
  102. package/dist/generated/models/channel_directories.js +1 -0
  103. package/dist/generated/models/channel_mention_mode.js +1 -0
  104. package/dist/generated/models/channel_models.js +1 -0
  105. package/dist/generated/models/channel_verbosity.js +1 -0
  106. package/dist/generated/models/channel_worktrees.js +1 -0
  107. package/dist/generated/models/forum_sync_configs.js +1 -0
  108. package/dist/generated/models/global_models.js +1 -0
  109. package/dist/generated/models/ipc_requests.js +1 -0
  110. package/dist/generated/models/part_messages.js +1 -0
  111. package/dist/generated/models/scheduled_tasks.js +1 -0
  112. package/dist/generated/models/session_agents.js +1 -0
  113. package/dist/generated/models/session_events.js +1 -0
  114. package/dist/generated/models/session_models.js +1 -0
  115. package/dist/generated/models/session_start_sources.js +1 -0
  116. package/dist/generated/models/thread_sessions.js +1 -0
  117. package/dist/generated/models/thread_worktrees.js +1 -0
  118. package/dist/generated/models.js +1 -0
  119. package/dist/heap-monitor.js +122 -0
  120. package/dist/hrana-server.js +263 -0
  121. package/dist/hrana-server.test.js +370 -0
  122. package/dist/html-actions.js +123 -0
  123. package/dist/html-actions.test.js +70 -0
  124. package/dist/html-components.js +117 -0
  125. package/dist/html-components.test.js +34 -0
  126. package/dist/image-optimizer-plugin.js +153 -0
  127. package/dist/image-utils.js +112 -0
  128. package/dist/interaction-handler.js +397 -0
  129. package/dist/ipc-polling.js +252 -0
  130. package/dist/ipc-tools-plugin.js +193 -0
  131. package/dist/kimaki-digital-twin.e2e.test.js +161 -0
  132. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +87 -0
  133. package/dist/kimaki-opencode-plugin.js +17 -0
  134. package/dist/kimaki-opencode-plugin.test.js +98 -0
  135. package/dist/limit-heading-depth.js +25 -0
  136. package/dist/limit-heading-depth.test.js +105 -0
  137. package/dist/logger.js +165 -0
  138. package/dist/markdown.js +342 -0
  139. package/dist/markdown.test.js +257 -0
  140. package/dist/message-finish-field.e2e.test.js +165 -0
  141. package/dist/message-formatting.js +413 -0
  142. package/dist/message-formatting.test.js +73 -0
  143. package/dist/message-preprocessing.js +330 -0
  144. package/dist/onboarding-tutorial.js +172 -0
  145. package/dist/onboarding-welcome.js +37 -0
  146. package/dist/openai-realtime.js +224 -0
  147. package/dist/opencode-command-detection.js +65 -0
  148. package/dist/opencode-command-detection.test.js +240 -0
  149. package/dist/opencode-command.js +129 -0
  150. package/dist/opencode-command.test.js +48 -0
  151. package/dist/opencode-interrupt-plugin.js +361 -0
  152. package/dist/opencode-interrupt-plugin.test.js +458 -0
  153. package/dist/opencode.js +861 -0
  154. package/dist/otto/branding.js +22 -0
  155. package/dist/otto/index.js +21 -0
  156. package/dist/parse-permission-rules.test.js +117 -0
  157. package/dist/patch-text-parser.js +97 -0
  158. package/dist/plugin-logger.js +59 -0
  159. package/dist/privacy-sanitizer.js +105 -0
  160. package/dist/queue-advanced-abort.e2e.test.js +293 -0
  161. package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
  162. package/dist/queue-advanced-e2e-setup.js +786 -0
  163. package/dist/queue-advanced-footer.e2e.test.js +472 -0
  164. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  165. package/dist/queue-advanced-permissions-typing.e2e.test.js +180 -0
  166. package/dist/queue-advanced-question.e2e.test.js +261 -0
  167. package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
  168. package/dist/queue-advanced-typing.e2e.test.js +153 -0
  169. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  170. package/dist/queue-interrupt-drain.e2e.test.js +135 -0
  171. package/dist/queue-question-select-drain.e2e.test.js +120 -0
  172. package/dist/runtime-idle-sweeper.js +52 -0
  173. package/dist/runtime-lifecycle.e2e.test.js +508 -0
  174. package/dist/sentry.js +23 -0
  175. package/dist/session-handler/agent-utils.js +67 -0
  176. package/dist/session-handler/event-stream-state.js +420 -0
  177. package/dist/session-handler/event-stream-state.test.js +563 -0
  178. package/dist/session-handler/model-utils.js +124 -0
  179. package/dist/session-handler/opencode-session-event-log.js +94 -0
  180. package/dist/session-handler/thread-runtime-state.js +104 -0
  181. package/dist/session-handler/thread-session-runtime.js +3258 -0
  182. package/dist/session-handler.js +9 -0
  183. package/dist/session-search.js +100 -0
  184. package/dist/session-search.test.js +40 -0
  185. package/dist/session-title-rename.test.js +80 -0
  186. package/dist/startup-service.js +153 -0
  187. package/dist/startup-time.e2e.test.js +296 -0
  188. package/dist/store.js +17 -0
  189. package/dist/system-message.js +613 -0
  190. package/dist/system-message.test.js +602 -0
  191. package/dist/task-runner.js +295 -0
  192. package/dist/task-schedule.js +209 -0
  193. package/dist/task-schedule.test.js +71 -0
  194. package/dist/test-utils.js +299 -0
  195. package/dist/thinking-utils.js +35 -0
  196. package/dist/thread-message-queue.e2e.test.js +999 -0
  197. package/dist/tools.js +357 -0
  198. package/dist/undo-redo.e2e.test.js +161 -0
  199. package/dist/unnest-code-blocks.js +146 -0
  200. package/dist/unnest-code-blocks.test.js +673 -0
  201. package/dist/upgrade.js +114 -0
  202. package/dist/utils.js +144 -0
  203. package/dist/voice-attachment.js +34 -0
  204. package/dist/voice-handler.js +646 -0
  205. package/dist/voice-message.e2e.test.js +1021 -0
  206. package/dist/voice.js +447 -0
  207. package/dist/voice.test.js +235 -0
  208. package/dist/wait-session.js +94 -0
  209. package/dist/websockify.js +69 -0
  210. package/dist/worker-types.js +4 -0
  211. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  212. package/dist/worktree-utils.js +3 -0
  213. package/dist/worktrees.js +929 -0
  214. package/dist/worktrees.test.js +189 -0
  215. package/dist/xml.js +92 -0
  216. package/dist/xml.test.js +32 -0
  217. package/package.json +98 -0
  218. package/schema.prisma +295 -0
  219. package/skills/batch/SKILL.md +87 -0
  220. package/skills/critique/SKILL.md +112 -0
  221. package/skills/egaki/SKILL.md +100 -0
  222. package/skills/errore/SKILL.md +647 -0
  223. package/skills/event-sourcing-state/SKILL.md +252 -0
  224. package/skills/gitchamber/SKILL.md +93 -0
  225. package/skills/goke/SKILL.md +644 -0
  226. package/skills/jitter/EDITOR.md +219 -0
  227. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  228. package/skills/jitter/SKILL.md +158 -0
  229. package/skills/jitter/jitter-clipboard.json +1042 -0
  230. package/skills/jitter/package.json +14 -0
  231. package/skills/jitter/tsconfig.json +15 -0
  232. package/skills/jitter/utils/actions.ts +212 -0
  233. package/skills/jitter/utils/export.ts +114 -0
  234. package/skills/jitter/utils/index.ts +141 -0
  235. package/skills/jitter/utils/snapshot.ts +154 -0
  236. package/skills/jitter/utils/traverse.ts +246 -0
  237. package/skills/jitter/utils/types.ts +279 -0
  238. package/skills/jitter/utils/wait.ts +133 -0
  239. package/skills/lintcn/SKILL.md +873 -0
  240. package/skills/new-skill/SKILL.md +211 -0
  241. package/skills/npm-package/SKILL.md +239 -0
  242. package/skills/playwriter/SKILL.md +35 -0
  243. package/skills/proxyman/SKILL.md +215 -0
  244. package/skills/security-review/SKILL.md +208 -0
  245. package/skills/simplify/SKILL.md +58 -0
  246. package/skills/spiceflow/SKILL.md +14 -0
  247. package/skills/termcast/SKILL.md +945 -0
  248. package/skills/tuistory/SKILL.md +250 -0
  249. package/skills/usecomputer/SKILL.md +264 -0
  250. package/skills/x-articles/SKILL.md +554 -0
  251. package/skills/zele/SKILL.md +112 -0
  252. package/skills/zustand-centralized-state/SKILL.md +1004 -0
  253. package/src/agent-model.e2e.test.ts +976 -0
  254. package/src/ai-tool-to-genai.test.ts +296 -0
  255. package/src/ai-tool-to-genai.ts +283 -0
  256. package/src/ai-tool.ts +39 -0
  257. package/src/anthropic-auth-plugin.test.ts +159 -0
  258. package/src/anthropic-auth-plugin.ts +861 -0
  259. package/src/anthropic-auth-state.ts +282 -0
  260. package/src/bin.ts +111 -0
  261. package/src/channel-management.ts +334 -0
  262. package/src/cli-parsing.test.ts +195 -0
  263. package/src/cli-send-thread.e2e.test.ts +464 -0
  264. package/src/cli.ts +4581 -0
  265. package/src/commands/abort.ts +89 -0
  266. package/src/commands/action-buttons.ts +364 -0
  267. package/src/commands/add-project.ts +149 -0
  268. package/src/commands/agent.ts +473 -0
  269. package/src/commands/ask-question.ts +390 -0
  270. package/src/commands/btw.ts +164 -0
  271. package/src/commands/compact.ts +157 -0
  272. package/src/commands/context-usage.ts +199 -0
  273. package/src/commands/create-new-project.ts +190 -0
  274. package/src/commands/diff.ts +91 -0
  275. package/src/commands/file-upload.ts +389 -0
  276. package/src/commands/fork.ts +321 -0
  277. package/src/commands/gemini-apikey.ts +104 -0
  278. package/src/commands/login.ts +1173 -0
  279. package/src/commands/mcp.ts +307 -0
  280. package/src/commands/memory-snapshot.ts +30 -0
  281. package/src/commands/mention-mode.ts +68 -0
  282. package/src/commands/merge-worktree.ts +223 -0
  283. package/src/commands/model-variant.ts +483 -0
  284. package/src/commands/model.ts +1053 -0
  285. package/src/commands/new-worktree.ts +510 -0
  286. package/src/commands/paginated-select.ts +81 -0
  287. package/src/commands/permissions.ts +397 -0
  288. package/src/commands/queue.ts +271 -0
  289. package/src/commands/remove-project.ts +155 -0
  290. package/src/commands/restart-opencode-server.ts +162 -0
  291. package/src/commands/resume.ts +230 -0
  292. package/src/commands/run-command.ts +123 -0
  293. package/src/commands/screenshare.test.ts +30 -0
  294. package/src/commands/screenshare.ts +366 -0
  295. package/src/commands/session-id.ts +109 -0
  296. package/src/commands/session.ts +227 -0
  297. package/src/commands/share.ts +106 -0
  298. package/src/commands/tasks.ts +293 -0
  299. package/src/commands/types.ts +25 -0
  300. package/src/commands/undo-redo.ts +386 -0
  301. package/src/commands/unset-model.ts +173 -0
  302. package/src/commands/upgrade.ts +52 -0
  303. package/src/commands/user-command.ts +198 -0
  304. package/src/commands/verbosity.ts +173 -0
  305. package/src/commands/worktree-settings.ts +70 -0
  306. package/src/commands/worktrees.ts +552 -0
  307. package/src/condense-memory.ts +36 -0
  308. package/src/config.ts +111 -0
  309. package/src/context-awareness-plugin.test.ts +142 -0
  310. package/src/context-awareness-plugin.ts +510 -0
  311. package/src/critique-utils.ts +139 -0
  312. package/src/database.ts +1876 -0
  313. package/src/db.test.ts +162 -0
  314. package/src/db.ts +286 -0
  315. package/src/debounce-timeout.ts +43 -0
  316. package/src/debounced-process-flush.ts +104 -0
  317. package/src/discord-bot.ts +1330 -0
  318. package/src/discord-command-registration.ts +693 -0
  319. package/src/discord-urls.ts +88 -0
  320. package/src/discord-utils.test.ts +153 -0
  321. package/src/discord-utils.ts +800 -0
  322. package/src/errors.ts +201 -0
  323. package/src/escape-backticks.test.ts +469 -0
  324. package/src/event-stream-real-capture.e2e.test.ts +692 -0
  325. package/src/eventsource-parser.test.ts +351 -0
  326. package/src/exec-async.ts +35 -0
  327. package/src/external-opencode-sync.ts +685 -0
  328. package/src/format-tables.test.ts +335 -0
  329. package/src/format-tables.ts +445 -0
  330. package/src/forum-sync/config.ts +92 -0
  331. package/src/forum-sync/discord-operations.ts +241 -0
  332. package/src/forum-sync/index.ts +9 -0
  333. package/src/forum-sync/markdown.ts +172 -0
  334. package/src/forum-sync/sync-to-discord.ts +595 -0
  335. package/src/forum-sync/sync-to-files.ts +294 -0
  336. package/src/forum-sync/types.ts +175 -0
  337. package/src/forum-sync/watchers.ts +454 -0
  338. package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
  339. package/src/gateway-proxy.e2e.test.ts +640 -0
  340. package/src/genai-worker-wrapper.ts +164 -0
  341. package/src/genai-worker.ts +386 -0
  342. package/src/genai.ts +321 -0
  343. package/src/generated/browser.ts +114 -0
  344. package/src/generated/client.ts +138 -0
  345. package/src/generated/commonInputTypes.ts +736 -0
  346. package/src/generated/enums.ts +88 -0
  347. package/src/generated/internal/class.ts +384 -0
  348. package/src/generated/internal/prismaNamespace.ts +2386 -0
  349. package/src/generated/internal/prismaNamespaceBrowser.ts +326 -0
  350. package/src/generated/models/bot_api_keys.ts +1288 -0
  351. package/src/generated/models/bot_tokens.ts +1656 -0
  352. package/src/generated/models/channel_agents.ts +1256 -0
  353. package/src/generated/models/channel_directories.ts +1859 -0
  354. package/src/generated/models/channel_mention_mode.ts +1300 -0
  355. package/src/generated/models/channel_models.ts +1288 -0
  356. package/src/generated/models/channel_verbosity.ts +1228 -0
  357. package/src/generated/models/channel_worktrees.ts +1300 -0
  358. package/src/generated/models/forum_sync_configs.ts +1452 -0
  359. package/src/generated/models/global_models.ts +1288 -0
  360. package/src/generated/models/ipc_requests.ts +1485 -0
  361. package/src/generated/models/part_messages.ts +1302 -0
  362. package/src/generated/models/scheduled_tasks.ts +2320 -0
  363. package/src/generated/models/session_agents.ts +1086 -0
  364. package/src/generated/models/session_events.ts +1439 -0
  365. package/src/generated/models/session_models.ts +1114 -0
  366. package/src/generated/models/session_start_sources.ts +1408 -0
  367. package/src/generated/models/thread_sessions.ts +1781 -0
  368. package/src/generated/models/thread_worktrees.ts +1356 -0
  369. package/src/generated/models.ts +30 -0
  370. package/src/heap-monitor.ts +152 -0
  371. package/src/hrana-server.test.ts +434 -0
  372. package/src/hrana-server.ts +314 -0
  373. package/src/html-actions.test.ts +87 -0
  374. package/src/html-actions.ts +174 -0
  375. package/src/html-components.test.ts +38 -0
  376. package/src/html-components.ts +181 -0
  377. package/src/image-optimizer-plugin.ts +194 -0
  378. package/src/image-utils.ts +149 -0
  379. package/src/interaction-handler.ts +576 -0
  380. package/src/ipc-polling.ts +326 -0
  381. package/src/ipc-tools-plugin.ts +236 -0
  382. package/src/kimaki-digital-twin.e2e.test.ts +199 -0
  383. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +109 -0
  384. package/src/kimaki-opencode-plugin.test.ts +108 -0
  385. package/src/kimaki-opencode-plugin.ts +18 -0
  386. package/src/limit-heading-depth.test.ts +116 -0
  387. package/src/limit-heading-depth.ts +26 -0
  388. package/src/logger.ts +208 -0
  389. package/src/markdown.test.ts +308 -0
  390. package/src/markdown.ts +410 -0
  391. package/src/message-finish-field.e2e.test.ts +192 -0
  392. package/src/message-formatting.test.ts +81 -0
  393. package/src/message-formatting.ts +533 -0
  394. package/src/message-preprocessing.ts +455 -0
  395. package/src/onboarding-tutorial.ts +176 -0
  396. package/src/onboarding-welcome.ts +49 -0
  397. package/src/openai-realtime.ts +358 -0
  398. package/src/opencode-command-detection.test.ts +307 -0
  399. package/src/opencode-command-detection.ts +76 -0
  400. package/src/opencode-command.test.ts +70 -0
  401. package/src/opencode-command.ts +188 -0
  402. package/src/opencode-interrupt-plugin.test.ts +677 -0
  403. package/src/opencode-interrupt-plugin.ts +477 -0
  404. package/src/opencode.ts +1110 -0
  405. package/src/otto/branding.ts +23 -0
  406. package/src/otto/index.ts +22 -0
  407. package/src/parse-permission-rules.test.ts +127 -0
  408. package/src/patch-text-parser.ts +107 -0
  409. package/src/plugin-logger.ts +68 -0
  410. package/src/privacy-sanitizer.ts +142 -0
  411. package/src/queue-advanced-abort.e2e.test.ts +382 -0
  412. package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
  413. package/src/queue-advanced-e2e-setup.ts +873 -0
  414. package/src/queue-advanced-footer.e2e.test.ts +576 -0
  415. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  416. package/src/queue-advanced-permissions-typing.e2e.test.ts +245 -0
  417. package/src/queue-advanced-question.e2e.test.ts +316 -0
  418. package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
  419. package/src/queue-advanced-typing.e2e.test.ts +199 -0
  420. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  421. package/src/queue-interrupt-drain.e2e.test.ts +166 -0
  422. package/src/queue-question-select-drain.e2e.test.ts +152 -0
  423. package/src/runtime-idle-sweeper.ts +76 -0
  424. package/src/runtime-lifecycle.e2e.test.ts +641 -0
  425. package/src/schema.sql +173 -0
  426. package/src/sentry.ts +26 -0
  427. package/src/session-handler/agent-utils.ts +97 -0
  428. package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
  429. package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
  430. package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
  431. package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
  432. package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
  433. package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
  434. package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
  435. package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
  436. package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
  437. package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
  438. package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
  439. package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
  440. package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
  441. package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
  442. package/src/session-handler/event-stream-state.test.ts +645 -0
  443. package/src/session-handler/event-stream-state.ts +608 -0
  444. package/src/session-handler/model-utils.ts +183 -0
  445. package/src/session-handler/opencode-session-event-log.ts +130 -0
  446. package/src/session-handler/thread-runtime-state.ts +212 -0
  447. package/src/session-handler/thread-session-runtime.ts +4281 -0
  448. package/src/session-handler.ts +15 -0
  449. package/src/session-search.test.ts +50 -0
  450. package/src/session-search.ts +148 -0
  451. package/src/session-title-rename.test.ts +112 -0
  452. package/src/startup-service.ts +200 -0
  453. package/src/startup-time.e2e.test.ts +373 -0
  454. package/src/store.ts +122 -0
  455. package/src/system-message.test.ts +612 -0
  456. package/src/system-message.ts +723 -0
  457. package/src/task-runner.ts +421 -0
  458. package/src/task-schedule.test.ts +84 -0
  459. package/src/task-schedule.ts +311 -0
  460. package/src/test-utils.ts +435 -0
  461. package/src/thinking-utils.ts +61 -0
  462. package/src/thread-message-queue.e2e.test.ts +1219 -0
  463. package/src/tools.ts +430 -0
  464. package/src/undici.d.ts +12 -0
  465. package/src/undo-redo.e2e.test.ts +209 -0
  466. package/src/unnest-code-blocks.test.ts +713 -0
  467. package/src/unnest-code-blocks.ts +185 -0
  468. package/src/upgrade.ts +127 -0
  469. package/src/utils.ts +212 -0
  470. package/src/voice-attachment.ts +51 -0
  471. package/src/voice-handler.ts +908 -0
  472. package/src/voice-message.e2e.test.ts +1255 -0
  473. package/src/voice.test.ts +281 -0
  474. package/src/voice.ts +627 -0
  475. package/src/wait-session.ts +147 -0
  476. package/src/websockify.ts +101 -0
  477. package/src/worker-types.ts +64 -0
  478. package/src/worktree-lifecycle.e2e.test.ts +391 -0
  479. package/src/worktree-utils.ts +4 -0
  480. package/src/worktrees.test.ts +223 -0
  481. package/src/worktrees.ts +1294 -0
  482. package/src/xml.test.ts +38 -0
  483. 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
+ }