@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,1330 @@
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
+
5
+ import {
6
+ initDatabase,
7
+ closeDatabase,
8
+ getThreadWorktree,
9
+ getThreadSession,
10
+ getChannelWorktreesEnabled,
11
+ getChannelMentionMode,
12
+ getChannelDirectory,
13
+ getPrisma,
14
+ cancelAllPendingIpcRequests,
15
+ deleteChannelDirectoryById,
16
+ createPendingWorktree,
17
+ setWorktreeReady,
18
+ } from './database.js'
19
+ import {
20
+ stopOpencodeServer,
21
+ } from './opencode.js'
22
+ import { formatWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js'
23
+ import { validateWorktreeDirectory, git } from './worktrees.js'
24
+ import { WORKTREE_PREFIX } from './commands/merge-worktree.js'
25
+ import {
26
+ escapeBackticksInCodeBlocks,
27
+ splitMarkdownForDiscord,
28
+ sendThreadMessage,
29
+ SILENT_MESSAGE_FLAGS,
30
+ NOTIFY_MESSAGE_FLAGS,
31
+ reactToThread,
32
+ stripMentions,
33
+ hasKimakiBotPermission,
34
+ hasNoKimakiRole,
35
+ } from './discord-utils.js'
36
+ import {
37
+ getOpencodeSystemMessage,
38
+ isInjectedPromptMarker,
39
+ type ThreadStartMarker,
40
+ } from './system-message.js'
41
+ import YAML from 'yaml'
42
+ import {
43
+ getTextAttachments,
44
+ resolveMentions,
45
+ } from './message-formatting.js'
46
+ import { isVoiceAttachment } from './voice-attachment.js'
47
+ import {
48
+ preprocessExistingThreadMessage,
49
+ preprocessNewThreadMessage,
50
+ } from './message-preprocessing.js'
51
+ import { cancelPendingActionButtons } from './commands/action-buttons.js'
52
+ import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js'
53
+ import { cancelPendingFileUpload } from './commands/file-upload.js'
54
+ import { cancelPendingPermission } from './commands/permissions.js'
55
+ import { cancelHtmlActionsForThread } from './html-actions.js'
56
+ import {
57
+ ensureKimakiCategory,
58
+ ensureKimakiAudioCategory,
59
+ createProjectChannels,
60
+ getChannelsWithDescriptions,
61
+ type ChannelWithTags,
62
+ } from './channel-management.js'
63
+ import {
64
+ voiceConnections,
65
+ cleanupVoiceConnection,
66
+ registerVoiceStateHandler,
67
+ } from './voice-handler.js'
68
+ import {
69
+ type SessionStartSourceContext,
70
+ } from './session-handler/model-utils.js'
71
+ import {
72
+ getRuntime,
73
+ getOrCreateRuntime,
74
+ disposeRuntime,
75
+ } from './session-handler/thread-session-runtime.js'
76
+ import { runShellCommand } from './commands/run-command.js'
77
+ import { registerInteractionHandler } from './interaction-handler.js'
78
+ import { getDiscordRestApiUrl } from './discord-urls.js'
79
+ import { markDiscordGatewayReady, stopHranaServer } from './hrana-server.js'
80
+ import { notifyError } from './sentry.js'
81
+ import { flushDebouncedProcessCallbacks } from './debounced-process-flush.js'
82
+ import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js'
83
+ import {
84
+ startExternalOpencodeSessionSync,
85
+ stopExternalOpencodeSessionSync,
86
+ } from './external-opencode-sync.js'
87
+
88
+ export {
89
+ initDatabase,
90
+ closeDatabase,
91
+ getChannelDirectory,
92
+ getPrisma,
93
+ } from './database.js'
94
+ export { initializeOpencodeForDirectory } from './opencode.js'
95
+ export {
96
+ escapeBackticksInCodeBlocks,
97
+ splitMarkdownForDiscord,
98
+ } from './discord-utils.js'
99
+ export { getOpencodeSystemMessage } from './system-message.js'
100
+ export {
101
+ ensureKimakiCategory,
102
+ ensureKimakiAudioCategory,
103
+ createProjectChannels,
104
+ createDefaultKimakiChannel,
105
+ getChannelsWithDescriptions,
106
+ } from './channel-management.js'
107
+ export type { ChannelWithTags } from './channel-management.js'
108
+
109
+ import {
110
+ ChannelType,
111
+ Client,
112
+ Events,
113
+ GatewayIntentBits,
114
+ Partials,
115
+ ThreadAutoArchiveDuration,
116
+ type Message,
117
+ type TextChannel,
118
+ type ThreadChannel,
119
+ } from 'discord.js'
120
+ import fs from 'node:fs'
121
+ import path from 'node:path'
122
+ import * as errore from 'errore'
123
+ import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js'
124
+ import { writeHeapSnapshot, startHeapMonitor } from './heap-monitor.js'
125
+ import { startTaskRunner } from './task-runner.js'
126
+ // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
127
+ // Each session's event.subscribe() holds a connection; without enough connections,
128
+ // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
129
+ // undici is a transitive dep from discord.js — not listed in our package.json.
130
+ // Types are declared in src/undici.d.ts.
131
+
132
+
133
+ const discordLogger = createLogger(LogPrefix.DISCORD)
134
+ const voiceLogger = createLogger(LogPrefix.VOICE)
135
+
136
+ // Well-known WebSocket and Discord Gateway close codes for diagnostic logging.
137
+ // Gateway proxy redeploys cause an abrupt TCP drop (code 1006) because the proxy
138
+ // doesn't send a close frame to clients before shutting down. discord.js then
139
+ // enters reconnection mode. The ShardReconnecting event intentionally strips the
140
+ // close code for recoverable disconnects, so we track it ourselves from the
141
+ // lower-level ShardDisconnect and ShardError events and correlate by shard ID.
142
+ function describeCloseCode(code: number): string {
143
+ const codes: Record<number, string> = {
144
+ 1000: 'normal closure',
145
+ 1001: 'going away',
146
+ 1006: 'abnormal closure (no close frame received)',
147
+ 1011: 'unexpected server error',
148
+ 1012: 'service restart',
149
+ 4000: 'unknown error',
150
+ 4001: 'unknown opcode',
151
+ 4002: 'decode error',
152
+ 4003: 'not authenticated',
153
+ 4004: 'authentication failed',
154
+ 4005: 'already authenticated',
155
+ 4007: 'invalid seq',
156
+ 4008: 'rate limited',
157
+ 4009: 'session timed out',
158
+ 4010: 'invalid shard',
159
+ 4011: 'sharding required',
160
+ 4012: 'invalid API version',
161
+ 4013: 'invalid intents',
162
+ 4014: 'disallowed intents',
163
+ }
164
+ return codes[code] || 'unknown'
165
+ }
166
+
167
+ // Per-shard state for tracking reconnection context.
168
+ // When discord.js fires ShardReconnecting it only provides the shard ID.
169
+ // We stash the last error / close code from preceding events so the
170
+ // reconnecting log line can include the actual cause.
171
+ interface ShardReconnectInfo {
172
+ lastError?: Error
173
+ lastDisconnectCode?: number
174
+ attempts: number
175
+ }
176
+ const shardReconnectState = new Map<number, ShardReconnectInfo>()
177
+
178
+ function getOrCreateShardState(shardId: number): ShardReconnectInfo {
179
+ let state = shardReconnectState.get(shardId)
180
+ if (!state) {
181
+ state = { attempts: 0 }
182
+ shardReconnectState.set(shardId, state)
183
+ }
184
+ return state
185
+ }
186
+
187
+ function parseEmbedFooterMarker<T extends Record<string, unknown>>({
188
+ footer,
189
+ }: {
190
+ footer: string | undefined
191
+ }): T | undefined {
192
+ if (!footer) {
193
+ return undefined
194
+ }
195
+ try {
196
+ const parsed = YAML.parse(footer)
197
+ if (!parsed || typeof parsed !== 'object') {
198
+ return undefined
199
+ }
200
+ return parsed as T
201
+ } catch {
202
+ return undefined
203
+ }
204
+ }
205
+
206
+ function parseSessionStartSourceFromMarker(
207
+ marker: ThreadStartMarker | undefined,
208
+ ): SessionStartSourceContext | undefined {
209
+ if (!marker?.scheduledKind) {
210
+ return undefined
211
+ }
212
+ if (marker.scheduledKind !== 'at' && marker.scheduledKind !== 'cron') {
213
+ return undefined
214
+ }
215
+ if (
216
+ typeof marker.scheduledTaskId !== 'number' ||
217
+ !Number.isInteger(marker.scheduledTaskId) ||
218
+ marker.scheduledTaskId < 1
219
+ ) {
220
+ return { scheduleKind: marker.scheduledKind }
221
+ }
222
+ return {
223
+ scheduleKind: marker.scheduledKind,
224
+ scheduledTaskId: marker.scheduledTaskId,
225
+ }
226
+ }
227
+
228
+ type StartOptions = {
229
+ token: string
230
+ appId?: string
231
+ /** When true, all new sessions from channel messages create git worktrees */
232
+ useWorktrees?: boolean
233
+ }
234
+
235
+ export async function createDiscordClient() {
236
+ // Read REST API URL lazily so gateway mode can set store.discordBaseUrl
237
+ // after module import but before client creation.
238
+ const restApiUrl = getDiscordRestApiUrl()
239
+ return new Client({
240
+ intents: [
241
+ GatewayIntentBits.Guilds,
242
+ GatewayIntentBits.GuildMessages,
243
+ GatewayIntentBits.MessageContent,
244
+ GatewayIntentBits.GuildVoiceStates,
245
+ ],
246
+ partials: [
247
+ Partials.Channel,
248
+ Partials.Message,
249
+ Partials.User,
250
+ Partials.ThreadMember,
251
+ ],
252
+ rest: { api: restApiUrl },
253
+ })
254
+ }
255
+
256
+ export async function startDiscordBot({
257
+ token,
258
+ appId,
259
+ discordClient,
260
+ useWorktrees,
261
+ }: StartOptions & { discordClient?: Client }) {
262
+ if (!discordClient) {
263
+ discordClient = await createDiscordClient()
264
+ }
265
+
266
+ let currentAppId: string | undefined = appId
267
+
268
+ const setupHandlers = async (c: Client<true>) => {
269
+ discordLogger.log(`Discord bot logged in as ${c.user.tag}`)
270
+ discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`)
271
+ discordLogger.log(`Bot user ID: ${c.user.id}`)
272
+
273
+ if (!currentAppId) {
274
+ await c.application?.fetch()
275
+ currentAppId = c.application?.id
276
+
277
+ if (!currentAppId) {
278
+ discordLogger.error('Could not get application ID')
279
+ throw new Error('Failed to get bot application ID')
280
+ }
281
+ discordLogger.log(`Bot Application ID (fetched): ${currentAppId}`)
282
+ } else {
283
+ discordLogger.log(`Bot Application ID (provided): ${currentAppId}`)
284
+ }
285
+
286
+ voiceLogger.log('[READY] Bot is ready')
287
+ markDiscordGatewayReady()
288
+
289
+ registerInteractionHandler({ discordClient: c, appId: currentAppId })
290
+ registerVoiceStateHandler({ discordClient: c, appId: currentAppId })
291
+ startExternalOpencodeSessionSync({ discordClient: c })
292
+
293
+ // Channel logging is informational only; do it in background so startup stays responsive.
294
+ void (async () => {
295
+ for (const guild of c.guilds.cache.values()) {
296
+ discordLogger.log(`${guild.name} (${guild.id})`)
297
+
298
+ const channels = await getChannelsWithDescriptions(guild)
299
+ const kimakiChannels = channels.filter((ch) => ch.kimakiDirectory)
300
+
301
+ if (kimakiChannels.length > 0) {
302
+ discordLogger.log(
303
+ ` Found ${kimakiChannels.length} channel(s) for this bot`,
304
+ )
305
+ continue
306
+ }
307
+
308
+ discordLogger.log(' No channels for this bot')
309
+ }
310
+ })().catch((error) => {
311
+ discordLogger.warn(
312
+ `Background guild channel scan failed: ${error instanceof Error ? error.stack : String(error)}`,
313
+ )
314
+ })
315
+ }
316
+
317
+ // If client is already ready (was logged in before being passed to us),
318
+ // run setup immediately. Otherwise wait for the ClientReady event.
319
+ if (discordClient.isReady()) {
320
+ await setupHandlers(discordClient)
321
+ } else {
322
+ discordClient.once(Events.ClientReady, (readyClient) => {
323
+ void setupHandlers(readyClient).catch((error) => {
324
+ discordLogger.error(
325
+ `[GATEWAY] ClientReady handler failed: ${formatErrorWithStack(error)}`,
326
+ )
327
+ })
328
+ })
329
+ }
330
+
331
+ discordClient.on(Events.Error, (error) => {
332
+ discordLogger.error('[GATEWAY] Client error:', formatErrorWithStack(error))
333
+ })
334
+
335
+ discordClient.on(Events.ShardError, (error, shardId) => {
336
+ const state = getOrCreateShardState(shardId)
337
+ state.lastError = error
338
+ discordLogger.error(
339
+ `[GATEWAY] Shard ${shardId} error: ${formatErrorWithStack(error)}`,
340
+ )
341
+ })
342
+
343
+ discordClient.on(Events.ShardDisconnect, (event, shardId) => {
344
+ // ShardDisconnect fires for unrecoverable close codes (4004, 4010-4014).
345
+ // For recoverable codes discord.js fires ShardReconnecting instead.
346
+ const state = getOrCreateShardState(shardId)
347
+ state.lastDisconnectCode = event.code
348
+ discordLogger.warn(
349
+ `[GATEWAY] Shard ${shardId} disconnected: code=${event.code} (${describeCloseCode(event.code)})`,
350
+ )
351
+ })
352
+
353
+ discordClient.on(Events.ShardReconnecting, (shardId) => {
354
+ // discord.js strips the close code before emitting this event.
355
+ // We log whatever context we captured from preceding ShardError events.
356
+ const state = getOrCreateShardState(shardId)
357
+ state.attempts++
358
+
359
+ const parts: string[] = [`attempt #${state.attempts}`]
360
+ if (state.lastDisconnectCode !== undefined) {
361
+ parts.push(`close code=${state.lastDisconnectCode} (${describeCloseCode(state.lastDisconnectCode)})`)
362
+ }
363
+ if (state.lastError) {
364
+ parts.push(`last error: ${state.lastError.message}`)
365
+ }
366
+ discordLogger.warn(
367
+ `[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`,
368
+ )
369
+ })
370
+
371
+ discordClient.on(Events.ShardResume, (shardId, replayedEvents) => {
372
+ const state = shardReconnectState.get(shardId)
373
+ if (state?.attempts) {
374
+ discordLogger.log(
375
+ `[GATEWAY] Shard ${shardId} resumed after ${state.attempts} reconnect attempt(s), ${replayedEvents} replayed events`,
376
+ )
377
+ } else {
378
+ discordLogger.log(
379
+ `[GATEWAY] Shard ${shardId} resumed, ${replayedEvents} replayed events`,
380
+ )
381
+ }
382
+ shardReconnectState.delete(shardId)
383
+ })
384
+
385
+ // ShardReady fires when a shard completes a fresh IDENTIFY (not RESUME).
386
+ // After a gateway proxy redeploy, sessions are lost (in-memory), so RESUME
387
+ // fails with INVALID_SESSION and discord.js falls back to fresh IDENTIFY.
388
+ discordClient.on(Events.ShardReady, (shardId) => {
389
+ const state = shardReconnectState.get(shardId)
390
+ if (state?.attempts) {
391
+ discordLogger.log(
392
+ `[GATEWAY] Shard ${shardId} ready after ${state.attempts} reconnect attempt(s)`,
393
+ )
394
+ }
395
+ shardReconnectState.delete(shardId)
396
+ })
397
+
398
+ discordClient.on(Events.Invalidated, () => {
399
+ discordLogger.error('[GATEWAY] Session invalidated by Discord')
400
+ })
401
+
402
+ discordClient.on(Events.MessageCreate, async (message: Message) => {
403
+ try {
404
+ const isSelfBotMessage = Boolean(
405
+ discordClient.user && message.author?.id === discordClient.user.id,
406
+ )
407
+ const promptMarker = parseEmbedFooterMarker<ThreadStartMarker>({
408
+ footer: message.embeds[0]?.footer?.text,
409
+ })
410
+ const isCliInjectedPrompt = Boolean(
411
+ isSelfBotMessage && isInjectedPromptMarker({ marker: promptMarker }),
412
+ )
413
+ const sessionStartSource = isCliInjectedPrompt
414
+ ? parseSessionStartSourceFromMarker(promptMarker)
415
+ : undefined
416
+ const cliInjectedUsername = isCliInjectedPrompt
417
+ ? promptMarker?.username || 'kimaki-cli'
418
+ : undefined
419
+ const cliInjectedUserId = isCliInjectedPrompt
420
+ ? promptMarker?.userId
421
+ : undefined
422
+ const cliInjectedAgent = isCliInjectedPrompt
423
+ ? promptMarker?.agent
424
+ : undefined
425
+ const cliInjectedModel = isCliInjectedPrompt
426
+ ? promptMarker?.model
427
+ : undefined
428
+ const cliInjectedPermissions = isCliInjectedPrompt
429
+ ? promptMarker?.permissions
430
+ : undefined
431
+ const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
432
+ ? promptMarker?.injectionGuardPatterns
433
+ : undefined
434
+
435
+ // Always ignore our own messages (unless CLI-injected prompt above).
436
+ // Without this, assigning the Kimaki role to the bot itself would loop.
437
+ if (isSelfBotMessage && !isCliInjectedPrompt) {
438
+ return
439
+ }
440
+
441
+ // Allow CLI-injected prompts from this Kimaki bot through even when role
442
+ // reconciliation did not give the bot the "Kimaki" role yet. Other bots
443
+ // still need Kimaki permission so multi-agent orchestration stays opt-in.
444
+ const isInjectedSelfBotMessage =
445
+ isCliInjectedPrompt && message.author?.id === discordClient.user?.id
446
+
447
+ if (message.author?.bot && !isInjectedSelfBotMessage) {
448
+ if (!hasKimakiBotPermission(message.member)) {
449
+ return
450
+ }
451
+ }
452
+
453
+ // Ignore messages that start with a mention of another user (not the bot).
454
+ // These are likely users talking to each other, not the bot.
455
+ const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/)
456
+ if (leadingMentionMatch) {
457
+ const mentionedUserId = leadingMentionMatch[1]
458
+ if (mentionedUserId !== discordClient.user?.id) {
459
+ return
460
+ }
461
+ }
462
+
463
+ if (message.partial) {
464
+ discordLogger.log(`Fetching partial message ${message.id}`)
465
+ const fetched = await errore.tryAsync({
466
+ try: () => message.fetch(),
467
+ catch: (e) => e as Error,
468
+ })
469
+ if (fetched instanceof Error) {
470
+ discordLogger.log(
471
+ `Failed to fetch partial message ${message.id}:`,
472
+ fetched.message,
473
+ )
474
+ return
475
+ }
476
+ }
477
+
478
+ // Check mention mode BEFORE permission check for text channels.
479
+ // When mention mode is enabled, users without Kimaki role can message
480
+ // without getting a permission error - we just silently ignore.
481
+ const channel = message.channel
482
+ if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
483
+ const textChannel = channel as TextChannel
484
+ const mentionModeEnabled = await getChannelMentionMode(textChannel.id)
485
+ if (mentionModeEnabled) {
486
+ const botMentioned =
487
+ discordClient.user && message.mentions.has(discordClient.user.id)
488
+ const isShellCommand = message.content?.startsWith('!')
489
+ if (!botMentioned && !isShellCommand) {
490
+ voiceLogger.log(`[IGNORED] Mention mode enabled, bot not mentioned`)
491
+ return
492
+ }
493
+ }
494
+ }
495
+
496
+ if (!isCliInjectedPrompt && message.guild && message.member) {
497
+ if (hasNoKimakiRole(message.member)) {
498
+ await message.reply({
499
+ content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
500
+ flags: SILENT_MESSAGE_FLAGS,
501
+ })
502
+ return
503
+ }
504
+
505
+ if (!hasKimakiBotPermission(message.member)) {
506
+ await message.reply({
507
+ content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
508
+ flags: SILENT_MESSAGE_FLAGS,
509
+ })
510
+ return
511
+ }
512
+ }
513
+
514
+ const isThread = [
515
+ ChannelType.PublicThread,
516
+ ChannelType.PrivateThread,
517
+ ChannelType.AnnouncementThread,
518
+ ].includes(channel.type)
519
+
520
+ if (isThread) {
521
+ const thread = channel as ThreadChannel
522
+ discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
523
+
524
+ // Only respond in threads kimaki knows about (has a session row in DB),
525
+ // where the bot is explicitly @mentioned, or where the bot created the
526
+ // thread itself (e.g. /new-worktree, /fork, kimaki send). This prevents
527
+ // the bot from hijacking user-created threads in project channels while
528
+ // still responding to bot-created threads that may not yet have a session
529
+ // row with a non-empty session_id (createPendingWorktree sets ''). (GitHub #84)
530
+ const hasExistingSession = await getThreadSession(thread.id)
531
+ const botMentioned =
532
+ discordClient.user && message.mentions.has(discordClient.user.id)
533
+ const botCreatedThread =
534
+ discordClient.user && thread.ownerId === discordClient.user.id
535
+ if (
536
+ !hasExistingSession &&
537
+ !botMentioned &&
538
+ !isCliInjectedPrompt &&
539
+ !botCreatedThread
540
+ ) {
541
+ discordLogger.log(
542
+ `Ignoring thread ${thread.id}: no existing session and bot not mentioned`,
543
+ )
544
+ return
545
+ }
546
+
547
+ const parent = thread.parent as TextChannel | null
548
+ let projectDirectory: string | undefined
549
+ if (parent) {
550
+ const channelConfig = await getChannelDirectory(parent.id)
551
+ if (channelConfig) {
552
+ projectDirectory = channelConfig.directory
553
+ }
554
+ }
555
+
556
+ // Check if this thread is a worktree thread.
557
+ // When the runtime exists in memory, pending worktrees are handled by
558
+ // the preprocess chain (messages queue behind the worktree promise).
559
+ // After a bot restart the runtime is gone, so we must reject messages
560
+ // for pending worktrees to avoid running in the base directory.
561
+ const worktreeInfo = await getThreadWorktree(thread.id)
562
+ if (worktreeInfo) {
563
+ if (worktreeInfo.status === 'pending' && !getRuntime(thread.id)) {
564
+ await message.reply({
565
+ content: '⏳ Worktree is still being created. Please wait...',
566
+ flags: SILENT_MESSAGE_FLAGS,
567
+ })
568
+ return
569
+ }
570
+ if (worktreeInfo.status === 'error') {
571
+ await message.reply({
572
+ content: `❌ Worktree creation failed: ${(worktreeInfo.error_message || '').slice(0, 1900)}`,
573
+ flags: NOTIFY_MESSAGE_FLAGS,
574
+ })
575
+ return
576
+ }
577
+ // Use original project directory for OpenCode server (session lives there)
578
+ // The worktree directory is passed via query.directory in prompt/command calls
579
+ if (worktreeInfo.project_directory) {
580
+ projectDirectory = worktreeInfo.project_directory
581
+ discordLogger.log(
582
+ `Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.worktree_directory})`,
583
+ )
584
+ }
585
+ }
586
+
587
+ if (projectDirectory && !fs.existsSync(projectDirectory)) {
588
+ discordLogger.error(`Directory does not exist: ${projectDirectory}`)
589
+ await message.reply({
590
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
591
+ flags: NOTIFY_MESSAGE_FLAGS,
592
+ })
593
+ return
594
+ }
595
+
596
+ // ! prefix runs a shell command instead of starting/continuing a session.
597
+ // Use worktree directory if available, so commands run in the worktree cwd.
598
+ // Skip shell commands while worktree is pending — they'd run in the base dir.
599
+ if (
600
+ message.content?.startsWith('!') &&
601
+ projectDirectory &&
602
+ worktreeInfo?.status !== 'pending'
603
+ ) {
604
+ const shellCmd = message.content.slice(1).trim()
605
+ if (shellCmd) {
606
+ const shellDir =
607
+ worktreeInfo?.status === 'ready' &&
608
+ worktreeInfo.worktree_directory
609
+ ? worktreeInfo.worktree_directory
610
+ : projectDirectory
611
+ const loadingReply = await message.reply({
612
+ content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
613
+ })
614
+ const result = await runShellCommand({
615
+ command: shellCmd,
616
+ directory: shellDir,
617
+ })
618
+ await loadingReply.edit({ content: result })
619
+ return
620
+ }
621
+ }
622
+
623
+ const hasVoiceAttachment = message.attachments.some((attachment) => {
624
+ return isVoiceAttachment(attachment)
625
+ })
626
+
627
+ if (!projectDirectory) {
628
+ discordLogger.log(
629
+ `Cannot process message: no project directory for thread ${thread.id}`,
630
+ )
631
+ return
632
+ }
633
+
634
+ const resolvedProjectDir = projectDirectory
635
+
636
+ const sdkDir =
637
+ worktreeInfo?.status === 'ready' &&
638
+ worktreeInfo.worktree_directory
639
+ ? worktreeInfo.worktree_directory
640
+ : resolvedProjectDir
641
+ const runtime = getOrCreateRuntime({
642
+ threadId: thread.id,
643
+ thread,
644
+ projectDirectory: resolvedProjectDir,
645
+ sdkDirectory: sdkDir,
646
+ channelId: parent?.id || undefined,
647
+ appId: currentAppId,
648
+ })
649
+
650
+ // Cancel interactive UI when a real user sends a message.
651
+ if (!message.author.bot && !isCliInjectedPrompt) {
652
+ cancelPendingActionButtons(thread.id)
653
+ cancelHtmlActionsForThread(thread.id)
654
+ const dismissedPermission = await cancelPendingPermission(thread.id)
655
+ if (dismissedPermission) {
656
+ await runtime.abortActiveRunAndWait({
657
+ reason: 'user sent a new message while permission was pending',
658
+ })
659
+ }
660
+ const dismissedQuestion = hasPendingQuestionForThread(thread.id)
661
+ if (dismissedQuestion) {
662
+ await cancelPendingQuestion(thread.id)
663
+ await runtime.abortActiveRunAndWait({
664
+ reason: 'user sent a new message while question was pending',
665
+ })
666
+ }
667
+ void cancelPendingFileUpload(thread.id)
668
+ }
669
+
670
+ // Expensive pre-processing (voice transcription, context fetch,
671
+ // attachment download) runs inside the runtime's serialized
672
+ // preprocess chain, preserving Discord arrival order without
673
+ // blocking SSE event handling in dispatchAction.
674
+ const enqueueResult = await runtime.enqueueIncoming({
675
+ prompt: '',
676
+ userId: cliInjectedUserId || message.author.id,
677
+ username:
678
+ cliInjectedUsername ||
679
+ message.member?.displayName ||
680
+ message.author.displayName,
681
+ sourceMessageId: message.id,
682
+ sourceThreadId: thread.id,
683
+ appId: currentAppId,
684
+ agent: cliInjectedAgent,
685
+ model: cliInjectedModel,
686
+ permissions: cliInjectedPermissions,
687
+ injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
688
+ sessionStartSource: sessionStartSource
689
+ ? {
690
+ scheduleKind: sessionStartSource.scheduleKind,
691
+ scheduledTaskId: sessionStartSource.scheduledTaskId,
692
+ }
693
+ : undefined,
694
+ preprocess: () => {
695
+ return preprocessExistingThreadMessage({
696
+ message,
697
+ thread,
698
+ projectDirectory: resolvedProjectDir,
699
+ channelId: parent?.id || undefined,
700
+ isCliInjected: isCliInjectedPrompt,
701
+ hasVoiceAttachment,
702
+ appId: currentAppId,
703
+ })
704
+ },
705
+ })
706
+
707
+ // Notify when a voice message was queued instead of sent immediately
708
+ if (enqueueResult.queued && enqueueResult.position) {
709
+ await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}`)
710
+ }
711
+ }
712
+
713
+ if (channel.type === ChannelType.GuildText) {
714
+ // `kimaki send` posts a starter message with a `start` embed marker,
715
+ // then creates the thread via REST. The ThreadCreate handler picks up
716
+ // that thread and starts the session. If we don't skip here, this
717
+ // handler races the CLI to call startThread() on the same message,
718
+ // causing DiscordAPIError[160004] "A thread has already been created
719
+ // for this message".
720
+ if (promptMarker?.start) {
721
+ return
722
+ }
723
+
724
+ const textChannel = channel as TextChannel
725
+ voiceLogger.log(
726
+ `[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`,
727
+ )
728
+
729
+ const channelConfig = await getChannelDirectory(textChannel.id)
730
+
731
+ if (!channelConfig) {
732
+ const botMentioned = Boolean(
733
+ discordClient.user && message.mentions.has(discordClient.user.id),
734
+ )
735
+ if (botMentioned) {
736
+ // TODO: Consider creating/using a session for any text channel when Kimaki is
737
+ // explicitly @mentioned, so the bot can answer quick questions even before
738
+ // the channel is linked to a project.
739
+ await message.reply({
740
+ content:
741
+ '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.',
742
+ flags: SILENT_MESSAGE_FLAGS,
743
+ })
744
+ return
745
+ }
746
+ voiceLogger.log(
747
+ `[IGNORED] Channel #${textChannel.name} has no project directory configured`,
748
+ )
749
+ return
750
+ }
751
+
752
+ const projectDirectory = channelConfig.directory
753
+
754
+ // Note: Mention mode is checked early in the handler (before permission check)
755
+ // to avoid sending permission errors to users who just didn't @mention the bot.
756
+
757
+ discordLogger.log(`DIRECTORY: Found kimaki.directory: ${projectDirectory}`)
758
+
759
+ if (!fs.existsSync(projectDirectory)) {
760
+ discordLogger.error(`Directory does not exist: ${projectDirectory}`)
761
+ await message.reply({
762
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
763
+ flags: NOTIFY_MESSAGE_FLAGS,
764
+ })
765
+ return
766
+ }
767
+
768
+ // ! prefix runs a shell command instead of starting a session
769
+ if (message.content?.startsWith('!')) {
770
+ const shellCmd = message.content.slice(1).trim()
771
+ if (shellCmd) {
772
+ const loadingReply = await message.reply({
773
+ content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
774
+ })
775
+ const result = await runShellCommand({
776
+ command: shellCmd,
777
+ directory: projectDirectory,
778
+ })
779
+ await loadingReply.edit({ content: result })
780
+ return
781
+ }
782
+ }
783
+
784
+ const hasVoice = message.attachments.some((attachment) => {
785
+ return isVoiceAttachment(attachment)
786
+ })
787
+
788
+ const baseThreadName = hasVoice
789
+ ? 'Voice Message'
790
+ : stripMentions(message.content || '')
791
+ .replace(/\s+/g, ' ')
792
+ .trim() || 'kimaki thread'
793
+
794
+ // Check if worktrees should be enabled (CLI flag OR channel setting)
795
+ const shouldUseWorktrees =
796
+ useWorktrees || (await getChannelWorktreesEnabled(textChannel.id))
797
+
798
+ // Add worktree prefix if worktrees are enabled
799
+ const threadName = shouldUseWorktrees
800
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
801
+ : baseThreadName
802
+
803
+ const thread = await message.startThread({
804
+ name: threadName.slice(0, 80),
805
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
806
+ reason: 'Start Claude session',
807
+ })
808
+
809
+ // Add user to thread so it appears in their sidebar
810
+ await thread.members.add(message.author.id)
811
+
812
+ discordLogger.log(`Created thread "${thread.name}" (${thread.id})`)
813
+
814
+ // Create runtime immediately so follow-up messages queue naturally
815
+ // via the preprocess chain instead of being rejected with "please wait".
816
+ // When worktrees are enabled, the worktree promise runs concurrently
817
+ // and the first message's preprocess callback awaits it before resolving.
818
+ let worktreePromise: Promise<string | Error> | undefined
819
+ if (shouldUseWorktrees) {
820
+ const worktreeName = formatWorktreeName(
821
+ hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50),
822
+ )
823
+ discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`)
824
+
825
+ const worktreeStatusMessage = await thread
826
+ .send({
827
+ content: worktreeCreatingMessage(worktreeName),
828
+ flags: SILENT_MESSAGE_FLAGS,
829
+ })
830
+ .catch(() => undefined)
831
+
832
+ worktreePromise = createWorktreeInBackground({
833
+ thread,
834
+ starterMessage: worktreeStatusMessage,
835
+ worktreeName,
836
+ projectDirectory,
837
+ rest: discordClient.rest,
838
+ })
839
+ }
840
+
841
+ const channelRuntime = getOrCreateRuntime({
842
+ threadId: thread.id,
843
+ thread,
844
+ projectDirectory,
845
+ sdkDirectory: projectDirectory,
846
+ channelId: textChannel.id,
847
+ appId: currentAppId,
848
+ })
849
+ await channelRuntime.enqueueIncoming({
850
+ prompt: '',
851
+ userId: message.author.id,
852
+ username:
853
+ message.member?.displayName || message.author.displayName,
854
+ sourceMessageId: message.id,
855
+ sourceThreadId: thread.id,
856
+ appId: currentAppId,
857
+ preprocess: async () => {
858
+ // Wait for worktree creation + install before preprocessing.
859
+ // Follow-up messages queue behind this in the preprocess chain.
860
+ let sessionDirectory = projectDirectory
861
+ if (worktreePromise) {
862
+ const result = await worktreePromise
863
+ if (!(result instanceof Error)) {
864
+ sessionDirectory = result
865
+ channelRuntime.handleDirectoryChanged({
866
+ oldDirectory: projectDirectory,
867
+ newDirectory: sessionDirectory,
868
+ })
869
+ }
870
+ }
871
+ return preprocessNewThreadMessage({
872
+ message,
873
+ thread,
874
+ projectDirectory: sessionDirectory,
875
+ hasVoiceAttachment: hasVoice,
876
+ appId: currentAppId,
877
+ })
878
+ },
879
+ })
880
+ } else {
881
+ // discordLogger.log(`Channel type ${channel.type} is not supported`)
882
+ }
883
+ } catch (error) {
884
+ voiceLogger.error('Discord handler error:', error)
885
+ void notifyError(error, 'MessageCreate handler error')
886
+ try {
887
+ const errMsg = (
888
+ error instanceof Error ? error.message : String(error)
889
+ ).slice(0, 1900)
890
+ await message.reply({
891
+ content: `Error: ${errMsg}`,
892
+ flags: NOTIFY_MESSAGE_FLAGS,
893
+ })
894
+ } catch (sendError) {
895
+ voiceLogger.error(
896
+ 'Discord handler error (fallback):',
897
+ sendError instanceof Error ? sendError.message : String(sendError),
898
+ )
899
+ }
900
+ }
901
+ })
902
+
903
+ // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
904
+ // Uses JSON embed marker to pass options (start, worktree name)
905
+ discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
906
+ try {
907
+ if (!newlyCreated) {
908
+ return
909
+ }
910
+
911
+ // Only handle threads in text channels
912
+ const parent = thread.parent as TextChannel | null
913
+ if (!parent || parent.type !== ChannelType.GuildText) {
914
+ return
915
+ }
916
+
917
+ // Get the starter message to check for auto-start marker
918
+ const starterMessage = await thread
919
+ .fetchStarterMessage()
920
+ .catch((error) => {
921
+ discordLogger.warn(
922
+ `[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`,
923
+ error instanceof Error ? error.stack : String(error),
924
+ )
925
+ return null
926
+ })
927
+ if (!starterMessage) {
928
+ discordLogger.log(
929
+ `[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`,
930
+ )
931
+ return
932
+ }
933
+
934
+ // Parse JSON marker from embed footer
935
+ const embedFooter = starterMessage.embeds[0]?.footer?.text
936
+ if (!embedFooter) {
937
+ return
938
+ }
939
+
940
+ // Only process markers from our own bot messages to prevent crafted embeds
941
+ if (starterMessage.author?.id !== discordClient.user?.id) {
942
+ return
943
+ }
944
+
945
+ const marker = parseEmbedFooterMarker<ThreadStartMarker>({
946
+ footer: embedFooter,
947
+ })
948
+ if (!marker) {
949
+ return
950
+ }
951
+
952
+ if (!marker.start) {
953
+ return // Not an auto-start thread
954
+ }
955
+
956
+ discordLogger.log(
957
+ `[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`,
958
+ )
959
+
960
+ const textAttachmentsContent = await getTextAttachments(starterMessage)
961
+ const messageText = resolveMentions(starterMessage).trim()
962
+ const prompt = textAttachmentsContent
963
+ ? `${messageText}\n\n${textAttachmentsContent}`
964
+ : messageText
965
+ if (!prompt) {
966
+ discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
967
+ return
968
+ }
969
+
970
+ // Get directory from database
971
+ const channelConfig = await getChannelDirectory(parent.id)
972
+
973
+ if (!channelConfig) {
974
+ discordLogger.log(
975
+ `[BOT_SESSION] No project directory configured for parent channel`,
976
+ )
977
+ return
978
+ }
979
+
980
+ const projectDirectory = channelConfig.directory
981
+
982
+ if (!fs.existsSync(projectDirectory)) {
983
+ discordLogger.error(
984
+ `[BOT_SESSION] Directory does not exist: ${projectDirectory}`,
985
+ )
986
+ await thread.send({
987
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
988
+ flags: NOTIFY_MESSAGE_FLAGS,
989
+ })
990
+ return
991
+ }
992
+
993
+ // Start worktree creation concurrently if requested.
994
+ // The runtime is created immediately so follow-up messages queue
995
+ // naturally; the worktree promise is awaited inside enqueueIncoming.
996
+ let worktreePromise: Promise<string | Error> | undefined
997
+ if (marker.worktree) {
998
+ discordLogger.log(`[BOT_SESSION] Creating worktree: ${marker.worktree}`)
999
+
1000
+ const worktreeStatusMessage = await thread
1001
+ .send({
1002
+ content: worktreeCreatingMessage(marker.worktree),
1003
+ flags: SILENT_MESSAGE_FLAGS,
1004
+ })
1005
+ .catch(() => undefined)
1006
+
1007
+ worktreePromise = createWorktreeInBackground({
1008
+ thread,
1009
+ starterMessage: worktreeStatusMessage,
1010
+ worktreeName: marker.worktree,
1011
+ projectDirectory,
1012
+ rest: discordClient.rest,
1013
+ })
1014
+ }
1015
+
1016
+ // --cwd: reuse an existing worktree directory. Revalidate at bot-time
1017
+ // (CLI validated at send-time but the path could become stale).
1018
+ // Store in thread_worktrees as ready with origin=external so
1019
+ // destructive actions (merge, delete) are gated.
1020
+ // --cwd: if it matches projectDirectory, ignore silently (already the default).
1021
+ // Otherwise revalidate as a git worktree and store with origin=external.
1022
+ let cwdDirectory: string | undefined
1023
+ if (marker.cwd) {
1024
+ const cwdResult = await validateWorktreeDirectory({
1025
+ projectDirectory,
1026
+ candidatePath: marker.cwd,
1027
+ })
1028
+ if (cwdResult instanceof Error) {
1029
+ discordLogger.error(`[BOT_SESSION] --cwd validation failed: ${cwdResult.message}`)
1030
+ await thread.send({
1031
+ content: `✗ --cwd validation failed: ${cwdResult.message.slice(0, 1900)}`,
1032
+ flags: NOTIFY_MESSAGE_FLAGS,
1033
+ })
1034
+ return
1035
+ }
1036
+
1037
+ // If cwd is the same as projectDirectory, skip worktree setup entirely
1038
+ if (path.resolve(cwdResult) !== path.resolve(projectDirectory)) {
1039
+ cwdDirectory = cwdResult
1040
+
1041
+
1042
+ // Resolve actual branch name instead of using directory basename
1043
+ const branchResult = await git(cwdDirectory, 'symbolic-ref --short HEAD')
1044
+ const cwdWorktreeName = branchResult instanceof Error
1045
+ ? path.basename(cwdDirectory)
1046
+ : branchResult
1047
+
1048
+ await createPendingWorktree({
1049
+ threadId: thread.id,
1050
+ worktreeName: cwdWorktreeName,
1051
+ projectDirectory,
1052
+ })
1053
+ await setWorktreeReady({
1054
+ threadId: thread.id,
1055
+ worktreeDirectory: cwdDirectory,
1056
+ })
1057
+
1058
+ // React with tree emoji to mark as worktree thread
1059
+ await reactToThread({
1060
+ rest: discordClient.rest,
1061
+ threadId: thread.id,
1062
+ channelId: parent.id,
1063
+ emoji: '🌳',
1064
+ })
1065
+ }
1066
+ }
1067
+
1068
+ discordLogger.log(
1069
+ `[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`,
1070
+ )
1071
+
1072
+ const botThreadStartSource = parseSessionStartSourceFromMarker(marker)
1073
+
1074
+ const runtime = getOrCreateRuntime({
1075
+ threadId: thread.id,
1076
+ thread,
1077
+ projectDirectory,
1078
+ sdkDirectory: projectDirectory,
1079
+ channelId: parent.id,
1080
+ appId: currentAppId,
1081
+ })
1082
+ await runtime.enqueueIncoming({
1083
+ prompt: '',
1084
+ userId: marker.userId || '',
1085
+ username: marker.username || 'bot',
1086
+ appId: currentAppId,
1087
+ agent: marker.agent,
1088
+ model: marker.model,
1089
+ permissions: marker.permissions,
1090
+ injectionGuardPatterns: marker.injectionGuardPatterns,
1091
+ mode: 'opencode',
1092
+ sessionStartSource: botThreadStartSource
1093
+ ? {
1094
+ scheduleKind: botThreadStartSource.scheduleKind,
1095
+ scheduledTaskId: botThreadStartSource.scheduledTaskId,
1096
+ }
1097
+ : undefined,
1098
+ preprocess: async () => {
1099
+ // Wait for worktree creation + install before starting session.
1100
+ if (worktreePromise) {
1101
+ const result = await worktreePromise
1102
+ if (!(result instanceof Error)) {
1103
+ runtime.handleDirectoryChanged({
1104
+ oldDirectory: projectDirectory,
1105
+ newDirectory: result,
1106
+ })
1107
+ }
1108
+ }
1109
+ // --cwd: switch sdkDirectory to the existing worktree path
1110
+ if (cwdDirectory) {
1111
+ runtime.handleDirectoryChanged({
1112
+ oldDirectory: projectDirectory,
1113
+ newDirectory: cwdDirectory,
1114
+ })
1115
+ }
1116
+ return { prompt, mode: 'opencode' }
1117
+ },
1118
+ })
1119
+ } catch (error) {
1120
+ voiceLogger.error(
1121
+ '[BOT_SESSION] Error handling bot-initiated thread:',
1122
+ error,
1123
+ )
1124
+ void notifyError(error, 'ThreadCreate handler error')
1125
+ try {
1126
+ const errMsg = (
1127
+ error instanceof Error ? error.message : String(error)
1128
+ ).slice(0, 1900)
1129
+ await thread.send({
1130
+ content: `Error: ${errMsg}`,
1131
+ flags: NOTIFY_MESSAGE_FLAGS,
1132
+ })
1133
+ } catch (sendError) {
1134
+ voiceLogger.error(
1135
+ '[BOT_SESSION] Failed to send error message:',
1136
+ sendError instanceof Error ? sendError.message : String(sendError),
1137
+ )
1138
+ }
1139
+ }
1140
+ })
1141
+
1142
+ // Dispose runtime when a thread is deleted so memory is freed immediately
1143
+ // instead of waiting for the idle sweeper (1 hour default).
1144
+ discordClient.on(Events.ThreadDelete, (thread) => {
1145
+ disposeRuntime(thread.id)
1146
+ })
1147
+
1148
+ // Clean up SQLite when a Discord channel is deleted so project list
1149
+ // doesn't show stale ghost entries. Thread runtimes inside the deleted
1150
+ // channel are disposed by their own ThreadDelete events from Discord.
1151
+ discordClient.on(Events.ChannelDelete, async (channel) => {
1152
+ try {
1153
+ const deleted = await deleteChannelDirectoryById(channel.id)
1154
+ if (deleted) {
1155
+ discordLogger.log(
1156
+ `Cleaned up channel_directories for deleted channel ${channel.id}`,
1157
+ )
1158
+ }
1159
+ } catch (error) {
1160
+ notifyError(
1161
+ error instanceof Error ? error : new Error(String(error)),
1162
+ `Failed to clean up channel_directories for deleted channel ${channel.id}`,
1163
+ )
1164
+ }
1165
+ })
1166
+
1167
+ // Skip login if the caller already connected the client (e.g. cli.ts logs in
1168
+ // before calling startDiscordBot). Calling login() again destroys the existing
1169
+ // WebSocket (close code 1000) and triggers a spurious ShardReconnecting event.
1170
+ if (!discordClient.isReady()) {
1171
+ await discordClient.login(token)
1172
+ }
1173
+
1174
+ startHeapMonitor()
1175
+ const stopTaskRunner = startTaskRunner({ token })
1176
+ const stopRuntimeIdleSweeper = startRuntimeIdleSweeper()
1177
+
1178
+ const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
1179
+ discordLogger.log(`Received ${signal}, cleaning up...`)
1180
+
1181
+ if ((global as any).shuttingDown) {
1182
+ discordLogger.log('Already shutting down, ignoring duplicate signal')
1183
+ return
1184
+ }
1185
+ ;(global as any).shuttingDown = true
1186
+
1187
+ try {
1188
+ await stopRuntimeIdleSweeper()
1189
+ await stopTaskRunner()
1190
+
1191
+ await flushDebouncedProcessCallbacks().catch((error) => {
1192
+ discordLogger.warn(
1193
+ 'Failed to flush debounced process callbacks:',
1194
+ error instanceof Error ? error.stack : String(error),
1195
+ )
1196
+ })
1197
+
1198
+ // Cancel pending IPC requests so plugin tools don't hang
1199
+ await cancelAllPendingIpcRequests().catch((e) => {
1200
+ discordLogger.warn(
1201
+ 'Failed to cancel pending IPC requests:',
1202
+ (e as Error).message,
1203
+ )
1204
+ })
1205
+
1206
+ const cleanupPromises: Promise<void>[] = []
1207
+ for (const [guildId] of voiceConnections) {
1208
+ voiceLogger.log(
1209
+ `[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
1210
+ )
1211
+ cleanupPromises.push(cleanupVoiceConnection(guildId))
1212
+ }
1213
+
1214
+ if (cleanupPromises.length > 0) {
1215
+ voiceLogger.log(
1216
+ `[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
1217
+ )
1218
+ await Promise.allSettled(cleanupPromises)
1219
+ discordLogger.log(`All voice connections cleaned up`)
1220
+ }
1221
+
1222
+ voiceLogger.log('[SHUTDOWN] Stopping OpenCode server')
1223
+ stopExternalOpencodeSessionSync()
1224
+ await stopOpencodeServer()
1225
+
1226
+ discordLogger.log('Closing database...')
1227
+ await closeDatabase()
1228
+
1229
+ discordLogger.log('Stopping hrana server...')
1230
+ await stopHranaServer()
1231
+
1232
+ discordLogger.log('Destroying Discord client...')
1233
+ discordClient.destroy()
1234
+
1235
+ discordLogger.log('Cleanup complete.')
1236
+ if (!skipExit) {
1237
+ process.exit(0)
1238
+ }
1239
+ } catch (error) {
1240
+ voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
1241
+ if (!skipExit) {
1242
+ process.exit(1)
1243
+ }
1244
+ }
1245
+ }
1246
+
1247
+ process.on('SIGTERM', async () => {
1248
+ try {
1249
+ await handleShutdown('SIGTERM')
1250
+ } catch (error) {
1251
+ voiceLogger.error('[SIGTERM] Error during shutdown:', error)
1252
+ process.exit(1)
1253
+ }
1254
+ })
1255
+
1256
+ process.on('SIGINT', async () => {
1257
+ try {
1258
+ await handleShutdown('SIGINT')
1259
+ } catch (error) {
1260
+ voiceLogger.error('[SIGINT] Error during shutdown:', error)
1261
+ process.exit(1)
1262
+ }
1263
+ })
1264
+
1265
+ process.on('SIGUSR1', () => {
1266
+ discordLogger.log('Received SIGUSR1, writing heap snapshot...')
1267
+ writeHeapSnapshot().catch((e) => {
1268
+ discordLogger.error(
1269
+ 'Failed to write heap snapshot:',
1270
+ e instanceof Error ? e.message : String(e),
1271
+ )
1272
+ })
1273
+ })
1274
+
1275
+ process.on('SIGUSR2', async () => {
1276
+ discordLogger.log('Received SIGUSR2, restarting after cleanup...')
1277
+ try {
1278
+ await handleShutdown('SIGUSR2', { skipExit: true })
1279
+ } catch (error) {
1280
+ voiceLogger.error('[SIGUSR2] Error during shutdown:', error)
1281
+ }
1282
+ const { spawn } = await import('node:child_process')
1283
+ // Strip __KIMAKI_CHILD so the new process goes through the respawn wrapper in bin.js.
1284
+ // V8 heap flags are already in process.execArgv from the initial spawn, and bin.ts
1285
+ // will re-inject them if missing, so no need to add them here.
1286
+ const env = { ...process.env }
1287
+ delete env.__KIMAKI_CHILD
1288
+ spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
1289
+ stdio: 'inherit',
1290
+ detached: true,
1291
+ cwd: process.cwd(),
1292
+ env,
1293
+ }).unref()
1294
+ process.exit(0)
1295
+ })
1296
+
1297
+ process.on('uncaughtException', (error) => {
1298
+ discordLogger.error('Uncaught exception:', formatErrorWithStack(error))
1299
+ notifyError(error, 'Uncaught exception in bot process')
1300
+ void handleShutdown('uncaughtException', { skipExit: true }).catch(
1301
+ (shutdownError) => {
1302
+ discordLogger.error(
1303
+ '[uncaughtException] shutdown failed:',
1304
+ formatErrorWithStack(shutdownError),
1305
+ )
1306
+ },
1307
+ )
1308
+ setTimeout(() => {
1309
+ process.exit(1)
1310
+ }, 250).unref()
1311
+ })
1312
+
1313
+ process.on('unhandledRejection', (reason, promise) => {
1314
+ if ((global as any).shuttingDown) {
1315
+ discordLogger.log('Ignoring unhandled rejection during shutdown:', reason)
1316
+ return
1317
+ }
1318
+ discordLogger.error(
1319
+ 'Unhandled rejection:',
1320
+ formatErrorWithStack(reason),
1321
+ 'at promise:',
1322
+ promise,
1323
+ )
1324
+ const error =
1325
+ reason instanceof Error
1326
+ ? reason
1327
+ : new Error(formatErrorWithStack(reason))
1328
+ void notifyError(error, 'Unhandled rejection in bot process')
1329
+ })
1330
+ }