@brianli/kimaki 0.4.72-brianli.1

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 (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
@@ -0,0 +1,1316 @@
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
+ createPendingWorktree,
10
+ setWorktreeReady,
11
+ setWorktreeError,
12
+ getChannelWorktreesEnabled,
13
+ getChannelMentionMode,
14
+ getChannelDirectory,
15
+ getThreadSession,
16
+ setThreadSession,
17
+ getPrisma,
18
+ cancelAllPendingIpcRequests,
19
+ } from './database.js'
20
+ import {
21
+ initializeOpencodeForDirectory,
22
+ getOpencodeServers,
23
+ } from './opencode.js'
24
+ import { formatWorktreeName } from './commands/worktree.js'
25
+ import { WORKTREE_PREFIX } from './commands/merge-worktree.js'
26
+ import { createWorktreeWithSubmodules } from './worktree-utils.js'
27
+ import {
28
+ escapeBackticksInCodeBlocks,
29
+ splitMarkdownForDiscord,
30
+ sendThreadMessage,
31
+ SILENT_MESSAGE_FLAGS,
32
+ reactToThread,
33
+ stripMentions,
34
+ hasKimakiBotPermission,
35
+ hasNoKimakiRole,
36
+ isGuildAllowed,
37
+ } from './discord-utils.js'
38
+ import {
39
+ getOpencodeSystemMessage,
40
+ type ThreadStartMarker,
41
+ } from './system-message.js'
42
+ import yaml from 'js-yaml'
43
+ import {
44
+ getFileAttachments,
45
+ getTextAttachments,
46
+ resolveMentions,
47
+ } from './message-formatting.js'
48
+ import {
49
+ ensureKimakiCategory,
50
+ ensureKimakiAudioCategory,
51
+ createProjectChannels,
52
+ getChannelsWithDescriptions,
53
+ type ChannelWithTags,
54
+ } from './channel-management.js'
55
+ import {
56
+ voiceConnections,
57
+ cleanupVoiceConnection,
58
+ processVoiceAttachment,
59
+ registerVoiceStateHandler,
60
+ } from './voice-handler.js'
61
+ import { getCompactSessionContext, getLastSessionId } from './markdown.js'
62
+ import {
63
+ handleOpencodeSession,
64
+ signalThreadInterrupt,
65
+ queueOrSendMessage,
66
+ abortControllers,
67
+ type SessionStartSourceContext,
68
+ } from './session-handler.js'
69
+ import { runShellCommand } from './commands/run-command.js'
70
+ import { registerInteractionHandler } from './interaction-handler.js'
71
+ import { stopHranaServer } from './hrana-server.js'
72
+ import { notifyError } from './sentry.js'
73
+
74
+ export {
75
+ initDatabase,
76
+ closeDatabase,
77
+ getChannelDirectory,
78
+ getPrisma,
79
+ } from './database.js'
80
+ export { initializeOpencodeForDirectory } from './opencode.js'
81
+ export {
82
+ escapeBackticksInCodeBlocks,
83
+ splitMarkdownForDiscord,
84
+ } from './discord-utils.js'
85
+ export { getOpencodeSystemMessage } from './system-message.js'
86
+ export {
87
+ ensureKimakiCategory,
88
+ ensureKimakiAudioCategory,
89
+ createProjectChannels,
90
+ getChannelsWithDescriptions,
91
+ } from './channel-management.js'
92
+ export type { ChannelWithTags } from './channel-management.js'
93
+
94
+ import {
95
+ ChannelType,
96
+ Client,
97
+ Events,
98
+ GatewayIntentBits,
99
+ Partials,
100
+ ThreadAutoArchiveDuration,
101
+ type Message,
102
+ type TextChannel,
103
+ type ThreadChannel,
104
+ } from 'discord.js'
105
+ import fs from 'node:fs'
106
+ import * as errore from 'errore'
107
+ import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js'
108
+ import { writeHeapSnapshot, startHeapMonitor } from './heap-monitor.js'
109
+ import { startTaskRunner } from './task-runner.js'
110
+ import { getDiscordApiBaseUrl } from './discord-api.js'
111
+ import { setGlobalDispatcher, Agent } from 'undici'
112
+
113
+ // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
114
+ // Each session's event.subscribe() holds a connection; without enough connections,
115
+ // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
116
+ setGlobalDispatcher(
117
+ new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }),
118
+ )
119
+
120
+ const discordLogger = createLogger(LogPrefix.DISCORD)
121
+ const voiceLogger = createLogger(LogPrefix.VOICE)
122
+
123
+ // Per-thread serial queue so messages (voice + text) in the same thread are
124
+ // processed one at a time in arrival order. Without this, a slow voice
125
+ // transcription can finish after a fast text message and abort its session.
126
+ const threadMessageQueue = new Map<string, Promise<void>>()
127
+
128
+ function parseEmbedFooterMarker<T extends Record<string, unknown>>({
129
+ footer,
130
+ }: {
131
+ footer: string | undefined
132
+ }): T | undefined {
133
+ if (!footer) {
134
+ return undefined
135
+ }
136
+ try {
137
+ const parsed = yaml.load(footer)
138
+ if (!parsed || typeof parsed !== 'object') {
139
+ return undefined
140
+ }
141
+ return parsed as T
142
+ } catch {
143
+ return undefined
144
+ }
145
+ }
146
+
147
+ function parseSessionStartSourceFromMarker(
148
+ marker: ThreadStartMarker | undefined,
149
+ ): SessionStartSourceContext | undefined {
150
+ if (!marker?.scheduledKind) {
151
+ return undefined
152
+ }
153
+ if (marker.scheduledKind !== 'at' && marker.scheduledKind !== 'cron') {
154
+ return undefined
155
+ }
156
+ if (
157
+ typeof marker.scheduledTaskId !== 'number' ||
158
+ !Number.isInteger(marker.scheduledTaskId) ||
159
+ marker.scheduledTaskId < 1
160
+ ) {
161
+ return { scheduleKind: marker.scheduledKind }
162
+ }
163
+ return {
164
+ scheduleKind: marker.scheduledKind,
165
+ scheduledTaskId: marker.scheduledTaskId,
166
+ }
167
+ }
168
+
169
+ type StartOptions = {
170
+ token: string
171
+ appId?: string
172
+ /** When true, all new sessions from channel messages create git worktrees */
173
+ useWorktrees?: boolean
174
+ }
175
+
176
+ export async function createDiscordClient() {
177
+ const apiBaseUrl = getDiscordApiBaseUrl()
178
+ return new Client({
179
+ intents: [
180
+ GatewayIntentBits.Guilds,
181
+ GatewayIntentBits.GuildMessages,
182
+ GatewayIntentBits.MessageContent,
183
+ GatewayIntentBits.GuildVoiceStates,
184
+ ],
185
+ partials: [
186
+ Partials.Channel,
187
+ Partials.Message,
188
+ Partials.User,
189
+ Partials.ThreadMember,
190
+ ],
191
+ rest: {
192
+ api: apiBaseUrl,
193
+ version: '10',
194
+ },
195
+ })
196
+ }
197
+
198
+ export async function startDiscordBot({
199
+ token,
200
+ appId,
201
+ discordClient,
202
+ useWorktrees,
203
+ }: StartOptions & { discordClient?: Client }) {
204
+ if (!discordClient) {
205
+ discordClient = await createDiscordClient()
206
+ }
207
+
208
+ let currentAppId: string | undefined = appId
209
+
210
+ const setupHandlers = async (c: Client<true>) => {
211
+ discordLogger.log(`Discord bot logged in as ${c.user.tag}`)
212
+ discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`)
213
+ discordLogger.log(`Bot user ID: ${c.user.id}`)
214
+
215
+ if (!currentAppId) {
216
+ await c.application?.fetch()
217
+ currentAppId = c.application?.id
218
+
219
+ if (!currentAppId) {
220
+ discordLogger.error('Could not get application ID')
221
+ throw new Error('Failed to get bot application ID')
222
+ }
223
+ discordLogger.log(`Bot Application ID (fetched): ${currentAppId}`)
224
+ } else {
225
+ discordLogger.log(`Bot Application ID (provided): ${currentAppId}`)
226
+ }
227
+
228
+ voiceLogger.log(
229
+ `[READY] Bot is ready and will only respond to channels with app ID: ${currentAppId}`,
230
+ )
231
+
232
+ registerInteractionHandler({ discordClient: c, appId: currentAppId })
233
+ registerVoiceStateHandler({ discordClient: c, appId: currentAppId })
234
+
235
+ // Channel logging is informational only; do it in background so startup stays responsive.
236
+ void (async () => {
237
+ for (const guild of c.guilds.cache.values()) {
238
+ discordLogger.log(`${guild.name} (${guild.id})`)
239
+
240
+ const channels = await getChannelsWithDescriptions(guild)
241
+ const kimakiChannels = channels.filter(
242
+ (ch) =>
243
+ ch.kimakiDirectory &&
244
+ (!ch.kimakiApp || ch.kimakiApp === currentAppId),
245
+ )
246
+
247
+ if (kimakiChannels.length > 0) {
248
+ discordLogger.log(
249
+ ` Found ${kimakiChannels.length} channel(s) for this bot:`,
250
+ )
251
+ for (const channel of kimakiChannels) {
252
+ discordLogger.log(
253
+ ` - #${channel.name}: ${channel.kimakiDirectory}`,
254
+ )
255
+ }
256
+ continue
257
+ }
258
+
259
+ discordLogger.log(' No channels for this bot')
260
+ }
261
+ })().catch((error) => {
262
+ discordLogger.warn(
263
+ `Background guild channel scan failed: ${error instanceof Error ? error.message : String(error)}`,
264
+ )
265
+ })
266
+ }
267
+
268
+ // If client is already ready (was logged in before being passed to us),
269
+ // run setup immediately. Otherwise wait for the ClientReady event.
270
+ if (discordClient.isReady()) {
271
+ await setupHandlers(discordClient)
272
+ } else {
273
+ discordClient.once(Events.ClientReady, setupHandlers)
274
+ }
275
+
276
+ discordClient.on(Events.MessageCreate, async (message: Message) => {
277
+ try {
278
+ if (!isGuildAllowed({ guildId: message.guildId })) {
279
+ return
280
+ }
281
+ const isSelfBotMessage = Boolean(
282
+ discordClient.user && message.author?.id === discordClient.user.id,
283
+ )
284
+ const promptMarker = parseEmbedFooterMarker<ThreadStartMarker>({
285
+ footer: message.embeds[0]?.footer?.text,
286
+ })
287
+ const isCliInjectedPrompt = Boolean(
288
+ isSelfBotMessage && promptMarker?.cliThreadPrompt,
289
+ )
290
+ const sessionStartSource = isCliInjectedPrompt
291
+ ? parseSessionStartSourceFromMarker(promptMarker)
292
+ : undefined
293
+ const cliInjectedUsername = isCliInjectedPrompt
294
+ ? promptMarker?.username || 'kimaki-cli'
295
+ : undefined
296
+ const cliInjectedUserId = isCliInjectedPrompt
297
+ ? promptMarker?.userId
298
+ : undefined
299
+ const cliInjectedAgent = isCliInjectedPrompt
300
+ ? promptMarker?.agent
301
+ : undefined
302
+ const cliInjectedModel = isCliInjectedPrompt
303
+ ? promptMarker?.model
304
+ : undefined
305
+
306
+ // Always ignore our own messages (unless CLI-injected prompt above).
307
+ // Without this, assigning the Kimaki role to the bot itself would loop.
308
+ if (isSelfBotMessage && !isCliInjectedPrompt) {
309
+ return
310
+ }
311
+
312
+ // Allow bot messages through if the bot has the "Kimaki" role assigned.
313
+ // This enables multi-agent orchestration where other bots (e.g. an
314
+ // orchestrator) can @mention Kimaki and trigger sessions like a human.
315
+ if (message.author?.bot) {
316
+ if (!hasKimakiBotPermission(message.member)) {
317
+ return
318
+ }
319
+ }
320
+
321
+ // Ignore messages that start with a mention of another user (not the bot).
322
+ // These are likely users talking to each other, not the bot.
323
+ const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/)
324
+ if (leadingMentionMatch) {
325
+ const mentionedUserId = leadingMentionMatch[1]
326
+ if (mentionedUserId !== discordClient.user?.id) {
327
+ return
328
+ }
329
+ }
330
+
331
+ if (message.partial) {
332
+ discordLogger.log(`Fetching partial message ${message.id}`)
333
+ const fetched = await errore.tryAsync({
334
+ try: () => message.fetch(),
335
+ catch: (e) => e as Error,
336
+ })
337
+ if (fetched instanceof Error) {
338
+ discordLogger.log(
339
+ `Failed to fetch partial message ${message.id}:`,
340
+ fetched.message,
341
+ )
342
+ return
343
+ }
344
+ }
345
+
346
+ // Check mention mode BEFORE permission check for text channels.
347
+ // When mention mode is enabled, users without Kimaki role can message
348
+ // without getting a permission error - we just silently ignore.
349
+ const channel = message.channel
350
+ if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
351
+ const textChannel = channel as TextChannel
352
+ const mentionModeEnabled = await getChannelMentionMode(textChannel.id)
353
+ if (mentionModeEnabled) {
354
+ const botMentioned =
355
+ discordClient.user && message.mentions.has(discordClient.user.id)
356
+ const isShellCommand = message.content?.startsWith('!')
357
+ if (!botMentioned && !isShellCommand) {
358
+ voiceLogger.log(`[IGNORED] Mention mode enabled, bot not mentioned`)
359
+ return
360
+ }
361
+ }
362
+ }
363
+
364
+ if (!isCliInjectedPrompt && message.guild && message.member) {
365
+ if (hasNoKimakiRole(message.member)) {
366
+ await message.reply({
367
+ content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
368
+ flags: SILENT_MESSAGE_FLAGS,
369
+ })
370
+ return
371
+ }
372
+
373
+ if (!hasKimakiBotPermission(message.member)) {
374
+ await message.reply({
375
+ content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
376
+ flags: SILENT_MESSAGE_FLAGS,
377
+ })
378
+ return
379
+ }
380
+ }
381
+
382
+ const isThread = [
383
+ ChannelType.PublicThread,
384
+ ChannelType.PrivateThread,
385
+ ChannelType.AnnouncementThread,
386
+ ].includes(channel.type)
387
+
388
+ if (isThread) {
389
+ const thread = channel as ThreadChannel
390
+ discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
391
+
392
+ const parent = thread.parent as TextChannel | null
393
+ let projectDirectory: string | undefined
394
+ let channelAppId: string | undefined
395
+
396
+ if (parent) {
397
+ const channelConfig = await getChannelDirectory(parent.id)
398
+ if (channelConfig) {
399
+ projectDirectory = channelConfig.directory
400
+ channelAppId = channelConfig.appId || undefined
401
+ }
402
+ }
403
+
404
+ // Check if this thread is a worktree thread
405
+ const worktreeInfo = await getThreadWorktree(thread.id)
406
+ if (worktreeInfo) {
407
+ if (worktreeInfo.status === 'pending') {
408
+ await message.reply({
409
+ content: '⏳ Worktree is still being created. Please wait...',
410
+ flags: SILENT_MESSAGE_FLAGS,
411
+ })
412
+ return
413
+ }
414
+ if (worktreeInfo.status === 'error') {
415
+ await message.reply({
416
+ content: `❌ Worktree creation failed: ${(worktreeInfo.error_message || '').slice(0, 1900)}`,
417
+ flags: SILENT_MESSAGE_FLAGS,
418
+ })
419
+ return
420
+ }
421
+ // Use original project directory for OpenCode server (session lives there)
422
+ // The worktree directory is passed via query.directory in prompt/command calls
423
+ if (worktreeInfo.project_directory) {
424
+ projectDirectory = worktreeInfo.project_directory
425
+ discordLogger.log(
426
+ `Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.worktree_directory})`,
427
+ )
428
+ }
429
+ }
430
+
431
+ if (channelAppId && channelAppId !== currentAppId) {
432
+ voiceLogger.log(
433
+ `[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
434
+ )
435
+ return
436
+ }
437
+
438
+ if (projectDirectory && !fs.existsSync(projectDirectory)) {
439
+ discordLogger.error(`Directory does not exist: ${projectDirectory}`)
440
+ await message.reply({
441
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
442
+ flags: SILENT_MESSAGE_FLAGS,
443
+ })
444
+ return
445
+ }
446
+
447
+ // ! prefix runs a shell command instead of starting/continuing a session
448
+ // Use worktree directory if available, so commands run in the worktree cwd
449
+ if (message.content?.startsWith('!') && projectDirectory) {
450
+ const shellCmd = message.content.slice(1).trim()
451
+ if (shellCmd) {
452
+ const shellDir =
453
+ worktreeInfo?.status === 'ready' &&
454
+ worktreeInfo.worktree_directory
455
+ ? worktreeInfo.worktree_directory
456
+ : projectDirectory
457
+ const loadingReply = await message.reply({
458
+ content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
459
+ })
460
+ const result = await runShellCommand({
461
+ command: shellCmd,
462
+ directory: shellDir,
463
+ })
464
+ await loadingReply.edit({ content: result })
465
+ return
466
+ }
467
+ }
468
+
469
+ // Chain onto per-thread queue so messages (voice transcription + text)
470
+ // are processed in Discord arrival order, not completion order.
471
+ const hasVoiceAttachment = message.attachments.some((a) => {
472
+ return a.contentType?.startsWith('audio/')
473
+ })
474
+
475
+ const prev = threadMessageQueue.get(thread.id)
476
+
477
+ // Snapshot active request state NOW, before prev task finishes.
478
+ // Voice messages skip the eager interrupt so the session stays alive during
479
+ // transcription. But processThreadMessage is serialized behind prev, so by
480
+ // the time it runs the prev task may have finished and the controller is gone.
481
+ // This snapshot lets queueOrSendMessage know there WAS an active request
482
+ // when the voice message arrived, so it should queue even if the controller
483
+ // is no longer active.
484
+ // Conservative: if prev exists, something is actively being processed, so
485
+ // we treat it as having an active request (avoids race where the async
486
+ // getThreadSession call lets the prev task finish first).
487
+ const hadActiveRequestOnArrival: boolean = await (async () => {
488
+ if (!hasVoiceAttachment) {
489
+ return false
490
+ }
491
+ if (prev) {
492
+ return true
493
+ }
494
+ const sid = await getThreadSession(thread.id)
495
+ if (!sid) {
496
+ return false
497
+ }
498
+ const controller = abortControllers.get(sid)
499
+ return Boolean(controller && !controller.signal.aborted)
500
+ })()
501
+ if (prev && !hasVoiceAttachment) {
502
+ // Another message is being processed — abort it immediately so this
503
+ // queued message can start as soon as possible.
504
+ // Voice messages are excluded: they need transcription first to detect
505
+ // "queue this message" intent. Interrupting before transcription would
506
+ // abort the running session, making queueOrSendMessage see no active
507
+ // request and send immediately instead of queueing.
508
+ const sdkDirectory =
509
+ worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
510
+ ? worktreeInfo.worktree_directory
511
+ : projectDirectory
512
+ signalThreadInterrupt({
513
+ threadId: thread.id,
514
+ serverDirectory: projectDirectory,
515
+ sdkDirectory,
516
+ })
517
+ }
518
+ const task = (prev || Promise.resolve()).then(
519
+ () => { return processThreadMessage() },
520
+ () => { return processThreadMessage() },
521
+ )
522
+ threadMessageQueue.set(thread.id, task)
523
+ void task.finally(() => {
524
+ if (threadMessageQueue.get(thread.id) === task) {
525
+ threadMessageQueue.delete(thread.id)
526
+ }
527
+ })
528
+ await task
529
+ return
530
+
531
+ async function processThreadMessage() {
532
+ const sessionId = await getThreadSession(thread.id)
533
+
534
+ // No existing session - start a new one (e.g., replying to a notification thread)
535
+ if (!sessionId) {
536
+ discordLogger.log(
537
+ `No session for thread ${thread.id}, starting new session`,
538
+ )
539
+
540
+ if (!projectDirectory) {
541
+ discordLogger.log(
542
+ `Cannot start session: no project directory for thread ${thread.id}`,
543
+ )
544
+ return
545
+ }
546
+
547
+ let prompt = resolveMentions(message)
548
+ const voiceResult = await processVoiceAttachment({
549
+ message,
550
+ thread,
551
+ projectDirectory,
552
+ appId: currentAppId,
553
+ })
554
+ if (voiceResult) {
555
+ prompt = `Voice message transcription from Discord user:\n\n${voiceResult.transcription}`
556
+ }
557
+
558
+ // If voice transcription failed and there's no text content, bail out
559
+ if (hasVoiceAttachment && !voiceResult && !prompt.trim()) {
560
+ return
561
+ }
562
+
563
+ const starterMessage = await thread
564
+ .fetchStarterMessage()
565
+ .catch((error) => {
566
+ discordLogger.warn(
567
+ `[SESSION] Failed to fetch starter message for thread ${thread.id}:`,
568
+ error instanceof Error ? error.message : String(error),
569
+ )
570
+ return null
571
+ })
572
+ if (starterMessage && starterMessage.content !== message.content) {
573
+ const starterTextAttachments = await getTextAttachments(starterMessage)
574
+ const starterContent = resolveMentions(starterMessage)
575
+ const starterText = starterTextAttachments
576
+ ? `${starterContent}\n\n${starterTextAttachments}`
577
+ : starterContent
578
+ if (starterText) {
579
+ prompt = `Context from thread:\n${starterText}\n\nUser request:\n${prompt}`
580
+ }
581
+ }
582
+
583
+ await handleOpencodeSession({
584
+ prompt,
585
+ thread,
586
+ projectDirectory,
587
+ channelId: parent?.id || '',
588
+ username:
589
+ cliInjectedUsername ||
590
+ message.member?.displayName ||
591
+ message.author.displayName,
592
+ userId: cliInjectedUserId || message.author.id,
593
+ appId: currentAppId,
594
+ sessionStartSource,
595
+ agent: cliInjectedAgent,
596
+ model: cliInjectedModel,
597
+ })
598
+ return
599
+ }
600
+
601
+ voiceLogger.log(
602
+ `[SESSION] Found session ${sessionId} for thread ${thread.id}`,
603
+ )
604
+
605
+ let messageContent = resolveMentions(message)
606
+ if (isCliInjectedPrompt) {
607
+ messageContent = message.content || ''
608
+ }
609
+ let currentSessionContext: string | undefined
610
+ let lastSessionContext: string | undefined
611
+
612
+ if (projectDirectory) {
613
+ try {
614
+ const getClient = await initializeOpencodeForDirectory(
615
+ projectDirectory,
616
+ { channelId: parent?.id },
617
+ )
618
+ if (getClient instanceof Error) {
619
+ voiceLogger.error(
620
+ `[SESSION] Failed to initialize OpenCode client:`,
621
+ getClient.message,
622
+ )
623
+ throw new Error(getClient.message)
624
+ }
625
+ const client = getClient()
626
+
627
+ // get current session context (without system prompt, it would be duplicated)
628
+ if (sessionId) {
629
+ const result = await getCompactSessionContext({
630
+ client,
631
+ sessionId: sessionId,
632
+ includeSystemPrompt: false,
633
+ maxMessages: 15,
634
+ })
635
+ if (errore.isOk(result)) {
636
+ currentSessionContext = result
637
+ }
638
+ }
639
+
640
+ // get last session context (with system prompt for project context)
641
+ const lastSessionResult = await getLastSessionId({
642
+ client,
643
+ excludeSessionId: sessionId,
644
+ })
645
+ const lastSessionId = errore.unwrapOr(lastSessionResult, null)
646
+ if (lastSessionId) {
647
+ const result = await getCompactSessionContext({
648
+ client,
649
+ sessionId: lastSessionId,
650
+ includeSystemPrompt: true,
651
+ maxMessages: 10,
652
+ })
653
+ if (errore.isOk(result)) {
654
+ lastSessionContext = result
655
+ }
656
+ }
657
+ } catch (e) {
658
+ voiceLogger.error(`Could not get session context:`, e)
659
+ void notifyError(e, 'Failed to get session context')
660
+ }
661
+ }
662
+
663
+ const voiceResult = await processVoiceAttachment({
664
+ message,
665
+ thread,
666
+ projectDirectory,
667
+ appId: currentAppId,
668
+ currentSessionContext,
669
+ lastSessionContext,
670
+ })
671
+ if (voiceResult) {
672
+ messageContent = `Voice message transcription from Discord user:\n\n${voiceResult.transcription}`
673
+ }
674
+
675
+ // If voice transcription failed (returned null) and there's no text content,
676
+ // bail out — don't fire deferred interrupt or send an empty prompt.
677
+ if (hasVoiceAttachment && !voiceResult && !messageContent.trim()) {
678
+ return
679
+ }
680
+
681
+ // If the transcription model detected "queue this message" intent,
682
+ // use queueOrSendMessage instead of sending immediately.
683
+ if (voiceResult?.queueMessage) {
684
+ const fileAttachments = await getFileAttachments(message)
685
+ const textAttachmentsContent = await getTextAttachments(message)
686
+ const promptWithAttachments = textAttachmentsContent
687
+ ? `${messageContent}\n\n${textAttachmentsContent}`
688
+ : messageContent
689
+ const username =
690
+ cliInjectedUsername ||
691
+ message.member?.displayName ||
692
+ message.author.displayName
693
+ const result = await queueOrSendMessage({
694
+ thread,
695
+ prompt: promptWithAttachments,
696
+ userId: isCliInjectedPrompt
697
+ ? cliInjectedUserId || message.author.id
698
+ : message.author.id,
699
+ username,
700
+ appId: currentAppId,
701
+ images: fileAttachments,
702
+ forceQueue: hadActiveRequestOnArrival,
703
+ })
704
+ if (result.action === 'queued') {
705
+ await sendThreadMessage(
706
+ thread,
707
+ `Queued (position: ${result.position}). Will be sent after current response.`,
708
+ )
709
+ return
710
+ }
711
+ if (result.action === 'sent') {
712
+ return
713
+ }
714
+ // no-session / no-directory: fall through to normal handleOpencodeSession flow
715
+ }
716
+
717
+ // For voice messages without queue intent, we deferred the interrupt
718
+ // until after transcription (to preserve active-request state for queue
719
+ // detection). Now that we know it's not a queue message, signal the
720
+ // interrupt so the running session aborts before the new prompt is sent.
721
+ if (hasVoiceAttachment && !voiceResult?.queueMessage) {
722
+ const sdkDirectory =
723
+ worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
724
+ ? worktreeInfo.worktree_directory
725
+ : projectDirectory
726
+ signalThreadInterrupt({
727
+ threadId: thread.id,
728
+ serverDirectory: projectDirectory,
729
+ sdkDirectory,
730
+ })
731
+ }
732
+
733
+ const fileAttachments = await getFileAttachments(message)
734
+ const textAttachmentsContent = await getTextAttachments(message)
735
+ const promptWithAttachments = textAttachmentsContent
736
+ ? `${messageContent}\n\n${textAttachmentsContent}`
737
+ : messageContent
738
+ await handleOpencodeSession({
739
+ prompt: promptWithAttachments,
740
+ thread,
741
+ projectDirectory,
742
+ originalMessage: message,
743
+ images: fileAttachments,
744
+ channelId: parent?.id,
745
+ username:
746
+ cliInjectedUsername ||
747
+ message.member?.displayName ||
748
+ message.author.displayName,
749
+ userId: isCliInjectedPrompt ? cliInjectedUserId : message.author.id,
750
+ appId: currentAppId,
751
+ sessionStartSource,
752
+ agent: cliInjectedAgent,
753
+ model: cliInjectedModel,
754
+ })
755
+ }
756
+ }
757
+
758
+ if (channel.type === ChannelType.GuildText) {
759
+ const textChannel = channel as TextChannel
760
+ voiceLogger.log(
761
+ `[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`,
762
+ )
763
+
764
+ const channelConfig = await getChannelDirectory(textChannel.id)
765
+
766
+ if (!channelConfig) {
767
+ voiceLogger.log(
768
+ `[IGNORED] Channel #${textChannel.name} has no project directory configured`,
769
+ )
770
+ return
771
+ }
772
+
773
+ const projectDirectory = channelConfig.directory
774
+ const channelAppId = channelConfig.appId || undefined
775
+
776
+ if (channelAppId && channelAppId !== currentAppId) {
777
+ voiceLogger.log(
778
+ `[IGNORED] Channel belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
779
+ )
780
+ return
781
+ }
782
+
783
+ // Note: Mention mode is checked early in the handler (before permission check)
784
+ // to avoid sending permission errors to users who just didn't @mention the bot.
785
+
786
+ discordLogger.log(`DIRECTORY: Found kimaki.directory: ${projectDirectory}`)
787
+ if (channelAppId) {
788
+ discordLogger.log(`APP: Channel app ID: ${channelAppId}`)
789
+ }
790
+
791
+ if (!fs.existsSync(projectDirectory)) {
792
+ discordLogger.error(`Directory does not exist: ${projectDirectory}`)
793
+ await message.reply({
794
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
795
+ flags: SILENT_MESSAGE_FLAGS,
796
+ })
797
+ return
798
+ }
799
+
800
+ // ! prefix runs a shell command instead of starting a session
801
+ if (message.content?.startsWith('!')) {
802
+ const shellCmd = message.content.slice(1).trim()
803
+ if (shellCmd) {
804
+ const loadingReply = await message.reply({
805
+ content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
806
+ })
807
+ const result = await runShellCommand({
808
+ command: shellCmd,
809
+ directory: projectDirectory,
810
+ })
811
+ await loadingReply.edit({ content: result })
812
+ return
813
+ }
814
+ }
815
+
816
+ const hasVoice = message.attachments.some((a) =>
817
+ a.contentType?.startsWith('audio/'),
818
+ )
819
+
820
+ const baseThreadName = hasVoice
821
+ ? 'Voice Message'
822
+ : stripMentions(message.content || '')
823
+ .replace(/\s+/g, ' ')
824
+ .trim() || 'kimaki thread'
825
+
826
+ // Check if worktrees should be enabled (CLI flag OR channel setting)
827
+ const shouldUseWorktrees =
828
+ useWorktrees || (await getChannelWorktreesEnabled(textChannel.id))
829
+
830
+ // Add worktree prefix if worktrees are enabled
831
+ const threadName = shouldUseWorktrees
832
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
833
+ : baseThreadName
834
+
835
+ const thread = await message.startThread({
836
+ name: threadName.slice(0, 80),
837
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
838
+ reason: 'Start Claude session',
839
+ })
840
+
841
+ // Add user to thread so it appears in their sidebar
842
+ await thread.members.add(message.author.id)
843
+
844
+ discordLogger.log(`Created thread "${thread.name}" (${thread.id})`)
845
+
846
+ // Create worktree if worktrees are enabled (CLI flag OR channel setting)
847
+ let sessionDirectory = projectDirectory
848
+ if (shouldUseWorktrees) {
849
+ const worktreeName = formatWorktreeName(
850
+ hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50),
851
+ )
852
+ discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`)
853
+
854
+ // Store pending worktree immediately so bot knows about it
855
+ await createPendingWorktree({
856
+ threadId: thread.id,
857
+ worktreeName,
858
+ projectDirectory,
859
+ })
860
+
861
+ const worktreeResult = await createWorktreeWithSubmodules({
862
+ directory: projectDirectory,
863
+ name: worktreeName,
864
+ })
865
+
866
+ if (worktreeResult instanceof Error) {
867
+ const errMsg = worktreeResult.message
868
+ discordLogger.error(`[WORKTREE] Creation failed: ${errMsg}`)
869
+ await setWorktreeError({
870
+ threadId: thread.id,
871
+ errorMessage: errMsg,
872
+ })
873
+ await thread.send({
874
+ content: `⚠️ Failed to create worktree: ${errMsg}\nUsing main project directory instead.`,
875
+ flags: SILENT_MESSAGE_FLAGS,
876
+ })
877
+ } else {
878
+ await setWorktreeReady({
879
+ threadId: thread.id,
880
+ worktreeDirectory: worktreeResult.directory,
881
+ })
882
+ sessionDirectory = worktreeResult.directory
883
+ discordLogger.log(
884
+ `[WORKTREE] Created: ${worktreeResult.directory} (branch: ${worktreeResult.branch})`,
885
+ )
886
+ // React with tree emoji to mark as worktree thread
887
+ await reactToThread({
888
+ rest: discordClient.rest,
889
+ threadId: thread.id,
890
+ channelId: thread.parentId || undefined,
891
+ emoji: '🌳',
892
+ })
893
+ }
894
+ }
895
+
896
+ let messageContent = resolveMentions(message)
897
+ const voiceResult = await processVoiceAttachment({
898
+ message,
899
+ thread,
900
+ projectDirectory: sessionDirectory,
901
+ isNewThread: true,
902
+ appId: currentAppId,
903
+ })
904
+ if (voiceResult) {
905
+ messageContent = `Voice message transcription from Discord user:\n\n${voiceResult.transcription}`
906
+ }
907
+
908
+ // If voice transcription failed and there's no text content, bail out
909
+ if (hasVoice && !voiceResult && !messageContent.trim()) {
910
+ return
911
+ }
912
+
913
+ const fileAttachments = await getFileAttachments(message)
914
+ const textAttachmentsContent = await getTextAttachments(message)
915
+ const promptWithAttachments = textAttachmentsContent
916
+ ? `${messageContent}\n\n${textAttachmentsContent}`
917
+ : messageContent
918
+ await handleOpencodeSession({
919
+ prompt: promptWithAttachments,
920
+ thread,
921
+ projectDirectory: sessionDirectory,
922
+ originalMessage: message,
923
+ images: fileAttachments,
924
+ channelId: textChannel.id,
925
+ username: message.member?.displayName || message.author.displayName,
926
+ userId: message.author.id,
927
+ appId: currentAppId,
928
+ })
929
+ } else {
930
+ discordLogger.log(`Channel type ${channel.type} is not supported`)
931
+ }
932
+ } catch (error) {
933
+ voiceLogger.error('Discord handler error:', error)
934
+ void notifyError(error, 'MessageCreate handler error')
935
+ try {
936
+ const errMsg = (
937
+ error instanceof Error ? error.message : String(error)
938
+ ).slice(0, 1900)
939
+ await message.reply({
940
+ content: `Error: ${errMsg}`,
941
+ flags: SILENT_MESSAGE_FLAGS,
942
+ })
943
+ } catch (sendError) {
944
+ voiceLogger.error(
945
+ 'Discord handler error (fallback):',
946
+ sendError instanceof Error ? sendError.message : String(sendError),
947
+ )
948
+ }
949
+ }
950
+ })
951
+
952
+ // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
953
+ // Uses JSON embed marker to pass options (start, worktree name)
954
+ discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
955
+ try {
956
+ if (!isGuildAllowed({ guildId: thread.guildId })) {
957
+ return
958
+ }
959
+ if (!newlyCreated) {
960
+ return
961
+ }
962
+
963
+ // Only handle threads in text channels
964
+ const parent = thread.parent as TextChannel | null
965
+ if (!parent || parent.type !== ChannelType.GuildText) {
966
+ return
967
+ }
968
+
969
+ // Get the starter message to check for auto-start marker
970
+ const starterMessage = await thread
971
+ .fetchStarterMessage()
972
+ .catch((error) => {
973
+ discordLogger.warn(
974
+ `[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`,
975
+ error instanceof Error ? error.message : String(error),
976
+ )
977
+ return null
978
+ })
979
+ if (!starterMessage) {
980
+ discordLogger.log(
981
+ `[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`,
982
+ )
983
+ return
984
+ }
985
+
986
+ // Parse JSON marker from embed footer
987
+ const embedFooter = starterMessage.embeds[0]?.footer?.text
988
+ if (!embedFooter) {
989
+ return
990
+ }
991
+
992
+ const marker = parseEmbedFooterMarker<ThreadStartMarker>({
993
+ footer: embedFooter,
994
+ })
995
+ if (!marker) {
996
+ return
997
+ }
998
+
999
+ if (!marker.start) {
1000
+ return // Not an auto-start thread
1001
+ }
1002
+
1003
+ discordLogger.log(
1004
+ `[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`,
1005
+ )
1006
+
1007
+ const textAttachmentsContent = await getTextAttachments(starterMessage)
1008
+ const messageText = resolveMentions(starterMessage).trim()
1009
+ const prompt = textAttachmentsContent
1010
+ ? `${messageText}\n\n${textAttachmentsContent}`
1011
+ : messageText
1012
+ if (!prompt) {
1013
+ discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
1014
+ return
1015
+ }
1016
+
1017
+ // Get directory from database
1018
+ const channelConfig = await getChannelDirectory(parent.id)
1019
+
1020
+ if (!channelConfig) {
1021
+ discordLogger.log(
1022
+ `[BOT_SESSION] No project directory configured for parent channel`,
1023
+ )
1024
+ return
1025
+ }
1026
+
1027
+ const projectDirectory = channelConfig.directory
1028
+ const channelAppId = channelConfig.appId || undefined
1029
+
1030
+ if (channelAppId && channelAppId !== currentAppId) {
1031
+ discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`)
1032
+ return
1033
+ }
1034
+
1035
+ if (!fs.existsSync(projectDirectory)) {
1036
+ discordLogger.error(
1037
+ `[BOT_SESSION] Directory does not exist: ${projectDirectory}`,
1038
+ )
1039
+ await thread.send({
1040
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
1041
+ flags: SILENT_MESSAGE_FLAGS,
1042
+ })
1043
+ return
1044
+ }
1045
+
1046
+ // Create worktree if requested
1047
+ const sessionDirectory: string = await (async () => {
1048
+ if (!marker.worktree) {
1049
+ return projectDirectory
1050
+ }
1051
+
1052
+ discordLogger.log(`[BOT_SESSION] Creating worktree: ${marker.worktree}`)
1053
+
1054
+ const worktreeStatusMessage = await thread
1055
+ .send({
1056
+ content: `🌳 Creating worktree: ${marker.worktree}\n⏳ Setting up (this can take a bit)...`,
1057
+ flags: SILENT_MESSAGE_FLAGS,
1058
+ })
1059
+ .catch(() => {
1060
+ return null
1061
+ })
1062
+
1063
+ await createPendingWorktree({
1064
+ threadId: thread.id,
1065
+ worktreeName: marker.worktree,
1066
+ projectDirectory,
1067
+ })
1068
+
1069
+ const worktreeResult = await createWorktreeWithSubmodules({
1070
+ directory: projectDirectory,
1071
+ name: marker.worktree,
1072
+ })
1073
+
1074
+ if (errore.isError(worktreeResult)) {
1075
+ discordLogger.error(
1076
+ `[BOT_SESSION] Worktree creation failed: ${worktreeResult.message}`,
1077
+ )
1078
+ await setWorktreeError({
1079
+ threadId: thread.id,
1080
+ errorMessage: worktreeResult.message,
1081
+ })
1082
+ await (worktreeStatusMessage?.edit({
1083
+ content: `⚠️ Failed to create worktree: ${worktreeResult.message}\nUsing main project directory instead.`,
1084
+ flags: SILENT_MESSAGE_FLAGS,
1085
+ }) ||
1086
+ thread.send({
1087
+ content: `⚠️ Failed to create worktree: ${worktreeResult.message}\nUsing main project directory instead.`,
1088
+ flags: SILENT_MESSAGE_FLAGS,
1089
+ }))
1090
+ return projectDirectory
1091
+ }
1092
+
1093
+ await setWorktreeReady({
1094
+ threadId: thread.id,
1095
+ worktreeDirectory: worktreeResult.directory,
1096
+ })
1097
+ discordLogger.log(
1098
+ `[BOT_SESSION] Worktree created: ${worktreeResult.directory}`,
1099
+ )
1100
+ // React with tree emoji to mark as worktree thread
1101
+ await reactToThread({
1102
+ rest: discordClient.rest,
1103
+ threadId: thread.id,
1104
+ channelId: thread.parentId || undefined,
1105
+ emoji: '🌳',
1106
+ })
1107
+ await (worktreeStatusMessage?.edit({
1108
+ content: `🌳 **Worktree ready: ${marker.worktree}**\n📁 \`${worktreeResult.directory}\`\n🌿 Branch: \`${worktreeResult.branch}\``,
1109
+ flags: SILENT_MESSAGE_FLAGS,
1110
+ }) ||
1111
+ thread.send({
1112
+ content: `🌳 **Worktree ready: ${marker.worktree}**\n📁 \`${worktreeResult.directory}\`\n🌿 Branch: \`${worktreeResult.branch}\``,
1113
+ flags: SILENT_MESSAGE_FLAGS,
1114
+ }))
1115
+ return worktreeResult.directory
1116
+ })()
1117
+
1118
+ discordLogger.log(
1119
+ `[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`,
1120
+ )
1121
+
1122
+ const botThreadStartSource = parseSessionStartSourceFromMarker(marker)
1123
+
1124
+ await handleOpencodeSession({
1125
+ prompt,
1126
+ thread,
1127
+ projectDirectory: sessionDirectory,
1128
+ channelId: parent.id,
1129
+ appId: currentAppId,
1130
+ username: marker.username,
1131
+ userId: marker.userId,
1132
+ agent: marker.agent,
1133
+ model: marker.model,
1134
+ sessionStartSource: botThreadStartSource,
1135
+ })
1136
+ } catch (error) {
1137
+ voiceLogger.error(
1138
+ '[BOT_SESSION] Error handling bot-initiated thread:',
1139
+ error,
1140
+ )
1141
+ void notifyError(error, 'ThreadCreate handler error')
1142
+ try {
1143
+ const errMsg = (
1144
+ error instanceof Error ? error.message : String(error)
1145
+ ).slice(0, 1900)
1146
+ await thread.send({
1147
+ content: `Error: ${errMsg}`,
1148
+ flags: SILENT_MESSAGE_FLAGS,
1149
+ })
1150
+ } catch (sendError) {
1151
+ voiceLogger.error(
1152
+ '[BOT_SESSION] Failed to send error message:',
1153
+ sendError instanceof Error ? sendError.message : String(sendError),
1154
+ )
1155
+ }
1156
+ }
1157
+ })
1158
+
1159
+ await discordClient.login(token)
1160
+
1161
+ startHeapMonitor()
1162
+ const stopTaskRunner = startTaskRunner({ token })
1163
+
1164
+ const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
1165
+ discordLogger.log(`Received ${signal}, cleaning up...`)
1166
+
1167
+ if ((global as any).shuttingDown) {
1168
+ discordLogger.log('Already shutting down, ignoring duplicate signal')
1169
+ return
1170
+ }
1171
+ ;(global as any).shuttingDown = true
1172
+
1173
+ try {
1174
+ await stopTaskRunner()
1175
+
1176
+ // Cancel pending IPC requests so plugin tools don't hang
1177
+ await cancelAllPendingIpcRequests().catch((e) => {
1178
+ discordLogger.warn(
1179
+ 'Failed to cancel pending IPC requests:',
1180
+ (e as Error).message,
1181
+ )
1182
+ })
1183
+
1184
+ const cleanupPromises: Promise<void>[] = []
1185
+ for (const [guildId] of voiceConnections) {
1186
+ voiceLogger.log(
1187
+ `[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
1188
+ )
1189
+ cleanupPromises.push(cleanupVoiceConnection(guildId))
1190
+ }
1191
+
1192
+ if (cleanupPromises.length > 0) {
1193
+ voiceLogger.log(
1194
+ `[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
1195
+ )
1196
+ await Promise.allSettled(cleanupPromises)
1197
+ discordLogger.log(`All voice connections cleaned up`)
1198
+ }
1199
+
1200
+ for (const [dir, server] of getOpencodeServers()) {
1201
+ if (!server.process.killed) {
1202
+ voiceLogger.log(
1203
+ `[SHUTDOWN] Stopping OpenCode server on port ${server.port} for ${dir}`,
1204
+ )
1205
+ server.process.kill('SIGTERM')
1206
+ }
1207
+ }
1208
+ getOpencodeServers().clear()
1209
+
1210
+ discordLogger.log('Closing database...')
1211
+ await closeDatabase()
1212
+
1213
+ discordLogger.log('Stopping hrana server...')
1214
+ await stopHranaServer()
1215
+
1216
+ discordLogger.log('Destroying Discord client...')
1217
+ discordClient.destroy()
1218
+
1219
+ discordLogger.log('Cleanup complete.')
1220
+ if (!skipExit) {
1221
+ process.exit(0)
1222
+ }
1223
+ } catch (error) {
1224
+ voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
1225
+ if (!skipExit) {
1226
+ process.exit(1)
1227
+ }
1228
+ }
1229
+ }
1230
+
1231
+ process.on('SIGTERM', async () => {
1232
+ try {
1233
+ await handleShutdown('SIGTERM')
1234
+ } catch (error) {
1235
+ voiceLogger.error('[SIGTERM] Error during shutdown:', error)
1236
+ process.exit(1)
1237
+ }
1238
+ })
1239
+
1240
+ process.on('SIGINT', async () => {
1241
+ try {
1242
+ await handleShutdown('SIGINT')
1243
+ } catch (error) {
1244
+ voiceLogger.error('[SIGINT] Error during shutdown:', error)
1245
+ process.exit(1)
1246
+ }
1247
+ })
1248
+
1249
+ process.on('SIGUSR1', () => {
1250
+ discordLogger.log('Received SIGUSR1, writing heap snapshot...')
1251
+ try {
1252
+ writeHeapSnapshot()
1253
+ } catch (e) {
1254
+ discordLogger.error(
1255
+ 'Failed to write heap snapshot:',
1256
+ e instanceof Error ? e.message : String(e),
1257
+ )
1258
+ }
1259
+ })
1260
+
1261
+ process.on('SIGUSR2', async () => {
1262
+ discordLogger.log('Received SIGUSR2, restarting after cleanup...')
1263
+ try {
1264
+ await handleShutdown('SIGUSR2', { skipExit: true })
1265
+ } catch (error) {
1266
+ voiceLogger.error('[SIGUSR2] Error during shutdown:', error)
1267
+ }
1268
+ const { spawn } = await import('node:child_process')
1269
+ // Strip __KIMAKI_CHILD so the new process goes through the respawn wrapper in bin.js.
1270
+ // V8 heap flags are already in process.execArgv from the initial spawn, and bin.ts
1271
+ // will re-inject them if missing, so no need to add them here.
1272
+ const env = { ...process.env }
1273
+ delete env.__KIMAKI_CHILD
1274
+ spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
1275
+ stdio: 'inherit',
1276
+ detached: true,
1277
+ cwd: process.cwd(),
1278
+ env,
1279
+ }).unref()
1280
+ process.exit(0)
1281
+ })
1282
+
1283
+ process.on('uncaughtException', (error) => {
1284
+ discordLogger.error('Uncaught exception:', formatErrorWithStack(error))
1285
+ notifyError(error, 'Uncaught exception in bot process')
1286
+ void handleShutdown('uncaughtException', { skipExit: true }).catch(
1287
+ (shutdownError) => {
1288
+ discordLogger.error(
1289
+ '[uncaughtException] shutdown failed:',
1290
+ formatErrorWithStack(shutdownError),
1291
+ )
1292
+ },
1293
+ )
1294
+ setTimeout(() => {
1295
+ process.exit(1)
1296
+ }, 250).unref()
1297
+ })
1298
+
1299
+ process.on('unhandledRejection', (reason, promise) => {
1300
+ if ((global as any).shuttingDown) {
1301
+ discordLogger.log('Ignoring unhandled rejection during shutdown:', reason)
1302
+ return
1303
+ }
1304
+ discordLogger.error(
1305
+ 'Unhandled rejection:',
1306
+ formatErrorWithStack(reason),
1307
+ 'at promise:',
1308
+ promise,
1309
+ )
1310
+ const error =
1311
+ reason instanceof Error
1312
+ ? reason
1313
+ : new Error(formatErrorWithStack(reason))
1314
+ void notifyError(error, 'Unhandled rejection in bot process')
1315
+ })
1316
+ }