kimaki 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/dist/agent-model.e2e.test.js +250 -164
  2. package/dist/anthropic-account-identity.js +9 -1
  3. package/dist/anthropic-auth-plugin.js +10 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bash-tool-schema-plugin.js +32 -0
  7. package/dist/bash-tool-schema-plugin.test.js +49 -0
  8. package/dist/bin.js +73 -19
  9. package/dist/cli-commands/maintenance.js +46 -7
  10. package/dist/cli-commands/project.js +11 -27
  11. package/dist/cli-commands/send.js +30 -1
  12. package/dist/cli-commands/session.js +322 -90
  13. package/dist/cli-commands/task.js +34 -15
  14. package/dist/cli-parsing.test.js +83 -4
  15. package/dist/cli-runner.js +44 -8
  16. package/dist/cli-runner.test.js +27 -0
  17. package/dist/cli-send-thread.e2e.test.js +9 -9
  18. package/dist/cli.js +32 -0
  19. package/dist/commands/abort.js +5 -1
  20. package/dist/commands/action-buttons.js +2 -1
  21. package/dist/commands/add-project.js +4 -7
  22. package/dist/commands/agent.js +164 -32
  23. package/dist/commands/ask-question.js +23 -33
  24. package/dist/commands/ask-question.test.js +43 -1
  25. package/dist/commands/btw.js +28 -6
  26. package/dist/commands/file-upload.js +2 -1
  27. package/dist/commands/fork-subagent.js +2 -4
  28. package/dist/commands/fork.js +4 -4
  29. package/dist/commands/last-sessions.js +17 -10
  30. package/dist/commands/login.js +6 -0
  31. package/dist/commands/merge-worktree.js +47 -14
  32. package/dist/commands/model-variant.js +8 -1
  33. package/dist/commands/model.js +16 -9
  34. package/dist/commands/multioauth.js +1 -0
  35. package/dist/commands/new-worktree.js +170 -102
  36. package/dist/commands/queue.js +3 -2
  37. package/dist/commands/resume.js +4 -4
  38. package/dist/commands/session.js +78 -63
  39. package/dist/commands/user-command.js +6 -8
  40. package/dist/commands/worktrees.js +5 -13
  41. package/dist/config.js +16 -1
  42. package/dist/cpu-profiler.js +211 -0
  43. package/dist/cpu-profiler.test.js +160 -0
  44. package/dist/database.js +152 -3
  45. package/dist/db.js +33 -0
  46. package/dist/db.test.js +267 -1
  47. package/dist/default-channel-provisioning.test.js +148 -0
  48. package/dist/discord-bot.js +517 -488
  49. package/dist/discord-command-registration.js +25 -10
  50. package/dist/discord-command-registration.test.js +17 -0
  51. package/dist/discord-utils.js +97 -5
  52. package/dist/discord-utils.test.js +151 -1
  53. package/dist/errors.js +10 -0
  54. package/dist/exec-async.js +17 -5
  55. package/dist/external-directory-permissions.e2e.test.js +11 -7
  56. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  57. package/dist/external-opencode-sync.js +94 -17
  58. package/dist/external-opencode-sync.test.js +206 -0
  59. package/dist/extract-sdk-error-message.test.js +13 -0
  60. package/dist/file-edit-log.js +197 -0
  61. package/dist/file-edit-log.test.js +258 -0
  62. package/dist/format-tables.js +52 -0
  63. package/dist/format-tables.test.js +93 -0
  64. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  65. package/dist/gateway-proxy.e2e.test.js +18 -18
  66. package/dist/git-worktree-core.js +182 -17
  67. package/dist/interaction-handler.js +416 -398
  68. package/dist/ipc-tools-plugin.js +68 -3
  69. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  70. package/dist/kimaki-opencode-plugin.js +9 -3
  71. package/dist/kimaki-workspace-adaptor.js +33 -10
  72. package/dist/logger.js +11 -1
  73. package/dist/markdown.js +3 -1
  74. package/dist/markdown.test.js +11 -3
  75. package/dist/message-formatting.js +181 -51
  76. package/dist/message-formatting.test.js +529 -22
  77. package/dist/message-preprocessing.js +105 -1
  78. package/dist/oauth-rotation-shared.js +10 -0
  79. package/dist/openai-auth-plugin.js +1 -0
  80. package/dist/openai-auth-state.js +1 -0
  81. package/dist/opencode-command.js +28 -0
  82. package/dist/opencode-command.test.js +109 -1
  83. package/dist/opencode-interrupt-plugin.js +4 -0
  84. package/dist/opencode-interrupt-plugin.test.js +24 -0
  85. package/dist/opencode.js +82 -15
  86. package/dist/project-add.test.js +62 -0
  87. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  88. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  89. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  90. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  91. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  92. package/dist/queue-advanced-e2e-setup.js +78 -5
  93. package/dist/queue-advanced-footer.e2e.test.js +60 -269
  94. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  95. package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
  96. package/dist/queue-advanced-question.e2e.test.js +32 -27
  97. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  98. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  99. package/dist/queue-delete-message.e2e.test.js +3 -3
  100. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  101. package/dist/queue-interrupt-drain.e2e.test.js +122 -12
  102. package/dist/queue-question-select-drain.e2e.test.js +34 -132
  103. package/dist/runtime-lifecycle.e2e.test.js +21 -51
  104. package/dist/schema.js +35 -1
  105. package/dist/session-handler/event-stream-state.js +391 -50
  106. package/dist/session-handler/event-stream-state.test.js +736 -8
  107. package/dist/session-handler/model-utils.js +172 -5
  108. package/dist/session-handler/model-utils.test.js +445 -0
  109. package/dist/session-handler/opencode-session-event-log.js +30 -1
  110. package/dist/session-handler/thread-ingress.test.js +41 -0
  111. package/dist/session-handler/thread-runtime-state.js +2 -0
  112. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  113. package/dist/session-handler/thread-session-runtime.js +574 -299
  114. package/dist/session-search.js +123 -4
  115. package/dist/session-search.test.js +248 -1
  116. package/dist/session-sleep.test.js +224 -0
  117. package/dist/session-title-rename.test.js +37 -39
  118. package/dist/sleep.e2e.test.js +494 -0
  119. package/dist/startup-time.e2e.test.js +2 -1
  120. package/dist/store.js +3 -0
  121. package/dist/system-message.js +102 -16
  122. package/dist/system-message.test.js +192 -15
  123. package/dist/task-runner.js +96 -5
  124. package/dist/task-schedule.js +87 -1
  125. package/dist/task-schedule.test.js +149 -1
  126. package/dist/test-utils.js +17 -9
  127. package/dist/thinking-utils.js +10 -0
  128. package/dist/thinking-utils.test.js +35 -0
  129. package/dist/thread-message-queue.e2e.test.js +65 -213
  130. package/dist/undo-redo.e2e.test.js +17 -9
  131. package/dist/voice-attachment.js +23 -1
  132. package/dist/voice-handler.js +21 -4
  133. package/dist/voice-message.e2e.test.js +144 -30
  134. package/dist/voice-routing-input.e2e.test.js +195 -0
  135. package/dist/voice-routing-question.e2e.test.js +125 -0
  136. package/dist/voice.js +28 -8
  137. package/dist/voice.test.js +96 -30
  138. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  139. package/dist/worktree-lifecycle.e2e.test.js +55 -36
  140. package/dist/worktrees.js +226 -443
  141. package/dist/worktrees.test.js +435 -58
  142. package/dist/xai-auth-plugin.js +1 -0
  143. package/dist/xai-auth-state.js +1 -0
  144. package/package.json +8 -6
  145. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  146. package/skills/critique/SKILL.md +4 -4
  147. package/skills/errore/SKILL.md +60 -218
  148. package/skills/goke/SKILL.md +150 -10
  149. package/skills/holocron/SKILL.md +154 -17
  150. package/skills/npm-package/SKILL.md +55 -35
  151. package/skills/profano/SKILL.md +17 -2
  152. package/skills/spiceflow/SKILL.md +39 -3
  153. package/skills/strada/SKILL.md +39 -0
  154. package/skills/zele/SKILL.md +19 -0
  155. package/src/agent-model.e2e.test.ts +287 -186
  156. package/src/anthropic-account-identity.ts +20 -1
  157. package/src/anthropic-auth-plugin.test.ts +19 -0
  158. package/src/anthropic-auth-plugin.ts +12 -16
  159. package/src/anthropic-auth-state.ts +1 -0
  160. package/src/bash-tool-schema-plugin.test.ts +55 -0
  161. package/src/bash-tool-schema-plugin.ts +46 -0
  162. package/src/bin.ts +78 -26
  163. package/src/cli-commands/maintenance.ts +59 -13
  164. package/src/cli-commands/project.ts +14 -35
  165. package/src/cli-commands/send.ts +31 -1
  166. package/src/cli-commands/session.ts +374 -114
  167. package/src/cli-commands/task.ts +34 -15
  168. package/src/cli-parsing.test.ts +103 -4
  169. package/src/cli-runner.test.ts +49 -0
  170. package/src/cli-runner.ts +44 -7
  171. package/src/cli-send-thread.e2e.test.ts +9 -11
  172. package/src/cli.ts +53 -0
  173. package/src/commands/abort.ts +6 -1
  174. package/src/commands/action-buttons.ts +2 -1
  175. package/src/commands/add-project.ts +4 -8
  176. package/src/commands/agent.ts +205 -29
  177. package/src/commands/ask-question.test.ts +48 -1
  178. package/src/commands/ask-question.ts +23 -33
  179. package/src/commands/btw.ts +40 -5
  180. package/src/commands/file-upload.ts +2 -1
  181. package/src/commands/fork-subagent.ts +2 -3
  182. package/src/commands/fork.ts +4 -3
  183. package/src/commands/last-sessions.ts +10 -4
  184. package/src/commands/login.ts +6 -0
  185. package/src/commands/merge-worktree.ts +54 -16
  186. package/src/commands/model-variant.ts +9 -1
  187. package/src/commands/model.ts +20 -9
  188. package/src/commands/multioauth.ts +1 -0
  189. package/src/commands/new-worktree.ts +138 -55
  190. package/src/commands/queue.ts +3 -2
  191. package/src/commands/resume.ts +4 -3
  192. package/src/commands/session.ts +94 -70
  193. package/src/commands/user-command.ts +6 -6
  194. package/src/commands/worktrees.ts +5 -12
  195. package/src/config.ts +18 -1
  196. package/src/cpu-profiler.test.ts +183 -0
  197. package/src/cpu-profiler.ts +293 -0
  198. package/src/database.ts +235 -3
  199. package/src/db.test.ts +293 -0
  200. package/src/db.ts +38 -0
  201. package/src/default-channel-provisioning.test.ts +155 -0
  202. package/src/discord-bot.ts +78 -36
  203. package/src/discord-command-registration.test.ts +20 -0
  204. package/src/discord-command-registration.ts +42 -12
  205. package/src/discord-utils.test.ts +176 -0
  206. package/src/discord-utils.ts +152 -7
  207. package/src/errors.ts +12 -0
  208. package/src/exec-async.ts +20 -11
  209. package/src/external-directory-permissions.e2e.test.ts +11 -7
  210. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  211. package/src/external-opencode-sync.test.ts +248 -0
  212. package/src/external-opencode-sync.ts +122 -19
  213. package/src/extract-sdk-error-message.test.ts +18 -0
  214. package/src/file-edit-log.test.ts +287 -0
  215. package/src/file-edit-log.ts +283 -0
  216. package/src/format-tables.test.ts +101 -0
  217. package/src/format-tables.ts +75 -0
  218. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  219. package/src/gateway-proxy.e2e.test.ts +18 -17
  220. package/src/git-worktree-core.ts +247 -20
  221. package/src/interaction-handler.ts +36 -13
  222. package/src/ipc-tools-plugin.ts +82 -5
  223. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  224. package/src/kimaki-opencode-plugin.ts +9 -3
  225. package/src/kimaki-workspace-adaptor.ts +40 -11
  226. package/src/logger.ts +14 -4
  227. package/src/markdown.test.ts +13 -3
  228. package/src/markdown.ts +4 -1
  229. package/src/message-formatting.test.ts +672 -25
  230. package/src/message-formatting.ts +242 -53
  231. package/src/message-preprocessing.ts +116 -1
  232. package/src/oauth-rotation-shared.ts +10 -0
  233. package/src/openai-auth-plugin.ts +1 -0
  234. package/src/openai-auth-state.ts +1 -0
  235. package/src/opencode-command.test.ts +140 -0
  236. package/src/opencode-command.ts +38 -0
  237. package/src/opencode-interrupt-plugin.test.ts +32 -0
  238. package/src/opencode-interrupt-plugin.ts +3 -0
  239. package/src/opencode.ts +115 -15
  240. package/src/project-add.test.ts +80 -0
  241. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  242. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  243. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  244. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  245. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  246. package/src/queue-advanced-e2e-setup.ts +83 -4
  247. package/src/queue-advanced-footer.e2e.test.ts +67 -315
  248. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  249. package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
  250. package/src/queue-advanced-question.e2e.test.ts +36 -30
  251. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  252. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  253. package/src/queue-delete-message.e2e.test.ts +3 -3
  254. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  255. package/src/queue-interrupt-drain.e2e.test.ts +133 -12
  256. package/src/queue-question-select-drain.e2e.test.ts +36 -161
  257. package/src/runtime-lifecycle.e2e.test.ts +21 -54
  258. package/src/schema.sql +15 -2
  259. package/src/schema.ts +37 -1
  260. package/src/session-handler/event-stream-state.test.ts +790 -7
  261. package/src/session-handler/event-stream-state.ts +568 -53
  262. package/src/session-handler/model-utils.test.ts +537 -0
  263. package/src/session-handler/model-utils.ts +281 -5
  264. package/src/session-handler/opencode-session-event-log.ts +31 -1
  265. package/src/session-handler/thread-ingress.test.ts +54 -0
  266. package/src/session-handler/thread-runtime-state.ts +3 -0
  267. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  268. package/src/session-handler/thread-session-runtime.ts +712 -358
  269. package/src/session-search.test.ts +281 -0
  270. package/src/session-search.ts +241 -7
  271. package/src/session-sleep.test.ts +294 -0
  272. package/src/session-title-rename.test.ts +54 -48
  273. package/src/sleep.e2e.test.ts +565 -0
  274. package/src/startup-time.e2e.test.ts +5 -1
  275. package/src/store.ts +22 -0
  276. package/src/system-message.test.ts +226 -15
  277. package/src/system-message.ts +130 -16
  278. package/src/task-runner.ts +138 -3
  279. package/src/task-schedule.test.ts +170 -0
  280. package/src/task-schedule.ts +141 -1
  281. package/src/test-utils.ts +21 -10
  282. package/src/thinking-utils.test.ts +46 -0
  283. package/src/thinking-utils.ts +21 -0
  284. package/src/thread-message-queue.e2e.test.ts +65 -245
  285. package/src/undo-redo.e2e.test.ts +17 -9
  286. package/src/voice-attachment.ts +29 -1
  287. package/src/voice-handler.ts +23 -4
  288. package/src/voice-message.e2e.test.ts +149 -29
  289. package/src/voice-routing-input.e2e.test.ts +203 -0
  290. package/src/voice-routing-question.e2e.test.ts +131 -0
  291. package/src/voice.test.ts +107 -29
  292. package/src/voice.ts +34 -6
  293. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  294. package/src/worktree-lifecycle.e2e.test.ts +55 -35
  295. package/src/worktrees.test.ts +505 -64
  296. package/src/worktrees.ts +257 -603
  297. package/src/xai-auth-plugin.ts +1 -0
  298. package/src/xai-auth-state.ts +1 -0
  299. package/dist/message-finish-field.e2e.test.js +0 -169
  300. package/dist/subagent-rate-limit-plugin.js +0 -184
  301. package/src/message-finish-field.e2e.test.ts +0 -196
  302. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -8,11 +8,12 @@
8
8
  // suppresses these when shuttingDown is already set, and we removeAllListeners()
9
9
  // before destroying the client to prevent late events from becoming uncaught.
10
10
  import { DiscordOperationError } from './errors.js';
11
- import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
11
+ import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, consumeSessionSleepWake, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
12
12
  import { stopOpencodeServer, } from './opencode.js';
13
13
  import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
14
14
  import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
15
15
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
16
+ import { STATUS_PREFIX } from './message-formatting.js';
16
17
  import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
17
18
  import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
18
19
  import YAML from 'yaml';
@@ -29,7 +30,7 @@ import { cancelHtmlActionsForThread } from './html-actions.js';
29
30
  import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
30
31
  import { voiceConnections, cleanupVoiceConnection, registerVoiceStateHandler, } from './voice-handler.js';
31
32
  import {} from './session-handler/model-utils.js';
32
- import { getRuntime, getOrCreateRuntime, disposeRuntime, } from './session-handler/thread-session-runtime.js';
33
+ import { getRuntime, getOrCreateRuntime, disposeRuntime, reserveThreadIngress, runInThreadIngressSlot, } from './session-handler/thread-session-runtime.js';
33
34
  import { runShellCommand } from './commands/run-command.js';
34
35
  import { registerInteractionHandler } from './interaction-handler.js';
35
36
  import { getDiscordRestApiUrl } from './discord-urls.js';
@@ -42,7 +43,7 @@ import { getDefaultKimakiDirectory, getUserProjectCount, } from './channel-manag
42
43
  import { store } from './store.js';
43
44
  import { startExternalOpencodeSessionSync, stopExternalOpencodeSessionSync, } from './external-opencode-sync.js';
44
45
  export { initDatabase, closeDatabase, getChannelDirectory, } from './database.js';
45
- export { initializeOpencodeForDirectory } from './opencode.js';
46
+ export { initializeOpencodeForDirectory, assertCompatibleOpencodeVersion } from './opencode.js';
46
47
  export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, } from './discord-utils.js';
47
48
  export { getOpencodeSystemMessage } from './system-message.js';
48
49
  export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, createDefaultKimakiChannel, getChannelsWithDescriptions, } from './channel-management.js';
@@ -52,6 +53,7 @@ import path from 'node:path';
52
53
  import dedent from 'string-dedent';
53
54
  import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
54
55
  import { writeHeapSnapshot, startHeapMonitor } from './heap-monitor.js';
56
+ import { flushCpuProfiling, startStdinCpuProfListener, stopStdinCpuProfListener, } from './cpu-profiler.js';
55
57
  import { startTaskRunner } from './task-runner.js';
56
58
  // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
57
59
  // Each session's event.subscribe() holds a connection; without enough connections,
@@ -277,8 +279,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
277
279
  discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
278
280
  if (state.attempts >= MAX_RECONNECT_ATTEMPTS) {
279
281
  discordLogger.error(`[GATEWAY] Shard ${shardId} exceeded ${MAX_RECONNECT_ATTEMPTS} reconnect attempts, self-restarting`);
280
- // Self-restart: cleanup, then exit non-zero so the bin.ts wrapper
281
- // restarts us. Without the wrapper this exits after logging a warning.
282
+ // Self-restart: cleanup, then SIGKILL so the bin.ts wrapper
283
+ // restarts us. Without the wrapper this dies after logging a warning.
282
284
  void selfRestart('gateway-reconnect-limit');
283
285
  }
284
286
  });
@@ -306,524 +308,554 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
306
308
  discordLogger.error('[GATEWAY] Session invalidated by Discord');
307
309
  });
308
310
  discordClient.on(Events.MessageCreate, async (message) => {
309
- try {
310
- const isSelfBotMessage = Boolean(discordClient.user && message.author?.id === discordClient.user.id);
311
- const promptMarker = parseEmbedFooterMarker({
312
- footer: message.embeds[0]?.footer?.text,
313
- });
314
- const isCliInjectedPrompt = Boolean(isSelfBotMessage && isInjectedPromptMarker({ marker: promptMarker }));
315
- const sessionStartSource = isCliInjectedPrompt
316
- ? parseSessionStartSourceFromMarker(promptMarker)
317
- : undefined;
318
- const cliInjectedUsername = isCliInjectedPrompt
319
- ? promptMarker?.username || 'kimaki-cli'
320
- : undefined;
321
- const cliInjectedUserId = isCliInjectedPrompt
322
- ? promptMarker?.userId
323
- : undefined;
324
- const cliInjectedAgent = isCliInjectedPrompt
325
- ? promptMarker?.agent
326
- : undefined;
327
- const cliInjectedModel = isCliInjectedPrompt
328
- ? promptMarker?.model
329
- : undefined;
330
- const cliInjectedPermissions = isCliInjectedPrompt
331
- ? promptMarker?.permissions
332
- : undefined;
333
- const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
334
- ? promptMarker?.injectionGuardPatterns
335
- : undefined;
336
- const cliInjectedParentSessionId = isCliInjectedPrompt
337
- ? promptMarker?.parentSessionId
338
- : undefined;
339
- // Always ignore our own messages (unless CLI-injected prompt above).
340
- // Without this, assigning the Kimaki role to the bot itself would loop.
341
- if (isSelfBotMessage && !isCliInjectedPrompt) {
342
- return;
343
- }
344
- // Allow CLI-injected prompts from this Kimaki bot through even when role
345
- // reconciliation did not give the bot the "Kimaki" role yet. Other bots
346
- // still need Kimaki permission so multi-agent orchestration stays opt-in.
347
- const isInjectedSelfBotMessage = isCliInjectedPrompt && message.author?.id === discordClient.user?.id;
348
- if (message.author?.bot && !isInjectedSelfBotMessage) {
349
- const member = await resolveGuildMessageMember(message);
350
- if (!hasKimakiBotPermission(member, message.guild)) {
351
- return;
352
- }
353
- }
354
- // Detect messages that start with a mention of another user (not the bot).
355
- // In channels these are fully ignored. In threads they are added to the
356
- // session context without triggering the AI (noReply), so the agent sees
357
- // user-to-user conversation on the next real turn.
358
- const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
359
- const isLeadingMentionToOtherUser = leadingMentionMatch &&
360
- leadingMentionMatch[1] !== discordClient.user?.id;
361
- if (message.partial) {
362
- discordLogger.log(`Fetching partial message ${message.id}`);
363
- const fetched = await message.fetch()
364
- .catch((e) => new DiscordOperationError({ operation: 'fetchMessage', cause: e }));
365
- if (fetched instanceof Error) {
366
- discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message);
311
+ const threadIngressSlot = message.channel.isThread() || message.channel.type === ChannelType.GuildText
312
+ ? reserveThreadIngress(message.channel.id)
313
+ : undefined;
314
+ await runInThreadIngressSlot(threadIngressSlot, async () => {
315
+ try {
316
+ const isSelfBotMessage = Boolean(discordClient.user && message.author?.id === discordClient.user.id);
317
+ const promptMarker = parseEmbedFooterMarker({
318
+ footer: message.embeds[0]?.footer?.text,
319
+ });
320
+ const isCliInjectedPrompt = Boolean(isSelfBotMessage && isInjectedPromptMarker({ marker: promptMarker }));
321
+ const sessionStartSource = isCliInjectedPrompt
322
+ ? parseSessionStartSourceFromMarker(promptMarker)
323
+ : undefined;
324
+ const cliInjectedUsername = isCliInjectedPrompt
325
+ ? promptMarker?.username || 'kimaki-cli'
326
+ : undefined;
327
+ const cliInjectedUserId = isCliInjectedPrompt
328
+ ? promptMarker?.userId
329
+ : undefined;
330
+ const cliInjectedAgent = isCliInjectedPrompt
331
+ ? promptMarker?.agent
332
+ : undefined;
333
+ const cliInjectedModel = isCliInjectedPrompt
334
+ ? promptMarker?.model
335
+ : undefined;
336
+ const cliInjectedPermissions = isCliInjectedPrompt
337
+ ? promptMarker?.permissions
338
+ : undefined;
339
+ const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
340
+ ? promptMarker?.injectionGuardPatterns
341
+ : undefined;
342
+ const cliInjectedParentSessionId = isCliInjectedPrompt
343
+ ? promptMarker?.parentSessionId
344
+ : undefined;
345
+ // Always ignore our own messages (unless CLI-injected prompt above).
346
+ // Without this, assigning the Kimaki role to the bot itself would loop.
347
+ if (isSelfBotMessage && !isCliInjectedPrompt) {
367
348
  return;
368
349
  }
369
- }
370
- // Check mention mode BEFORE permission check for text channels.
371
- // When mention mode is enabled, users without Kimaki role can message
372
- // without getting a permission error - we just silently ignore.
373
- const channel = message.channel;
374
- // In text channels, messages starting with a mention to another user
375
- // are fully ignored before any permission or mention-mode checks.
376
- // This prevents permission-error replies for user-to-user conversation.
377
- if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
378
- return;
379
- }
380
- if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
381
- const mentionModeEnabled = await getChannelMentionMode(channel.id);
382
- if (mentionModeEnabled) {
383
- const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
384
- const isShellCommand = message.content?.startsWith('!');
385
- if (!botMentioned && !isShellCommand) {
386
- voiceLogger.log(`[IGNORED] Mention mode enabled, bot not mentioned`);
350
+ // Allow CLI-injected prompts from this Kimaki bot through even when role
351
+ // reconciliation did not give the bot the "Kimaki" role yet. Other bots
352
+ // still need Kimaki permission so multi-agent orchestration stays opt-in.
353
+ const isInjectedSelfBotMessage = isCliInjectedPrompt && message.author?.id === discordClient.user?.id;
354
+ if (message.author?.bot && !isInjectedSelfBotMessage) {
355
+ const member = await resolveGuildMessageMember(message);
356
+ if (!hasKimakiBotPermission(member, message.guild)) {
387
357
  return;
388
358
  }
389
359
  }
390
- }
391
- // Multi-machine routing: check channel ownership before permission
392
- // checks so we don't send permission-denial replies in channels owned
393
- // by another machine. Resolve the "owning" channel: for threads, check
394
- // the parent channel; for text channels, check the channel itself.
395
- if (!isCliInjectedPrompt && message.guild) {
396
- const channel = message.channel;
397
- let owningChannelId;
398
- if ([
399
- ChannelType.PublicThread,
400
- ChannelType.PrivateThread,
401
- ChannelType.AnnouncementThread,
402
- ].includes(channel.type)) {
403
- const thread = channel;
404
- owningChannelId = thread.parent?.id || thread.parentId || undefined;
405
- }
406
- else {
407
- owningChannelId = channel.id;
408
- }
409
- if (owningChannelId) {
410
- const channelConfig = await getChannelDirectory(owningChannelId);
411
- if (!channelConfig) {
412
- voiceLogger.log(`[IGNORED] Channel ${owningChannelId} has no project directory configured`);
360
+ // Detect messages that start with a mention of another user (not the bot).
361
+ // In channels these are fully ignored. In threads they are added to the
362
+ // session context without triggering the AI (noReply), so the agent sees
363
+ // user-to-user conversation on the next real turn.
364
+ const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
365
+ const isLeadingMentionToOtherUser = leadingMentionMatch &&
366
+ leadingMentionMatch[1] !== discordClient.user?.id;
367
+ if (message.partial) {
368
+ discordLogger.log(`Fetching partial message ${message.id}`);
369
+ const fetched = await message.fetch()
370
+ .catch((e) => new DiscordOperationError({ operation: 'fetchMessage', cause: e }));
371
+ if (fetched instanceof Error) {
372
+ discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message);
413
373
  return;
414
374
  }
415
375
  }
416
- }
417
- if (!isCliInjectedPrompt && message.guild) {
418
- const member = await resolveGuildMessageMember(message);
419
- if (!member) {
420
- return;
421
- }
422
- if (hasNoKimakiRole(member)) {
423
- await message.reply({
424
- content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
425
- flags: SILENT_MESSAGE_FLAGS,
426
- });
427
- return;
428
- }
429
- if (!hasKimakiBotPermission(member, message.guild)) {
430
- await message.reply({
431
- content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
432
- flags: SILENT_MESSAGE_FLAGS,
433
- });
434
- return;
435
- }
436
- }
437
- const isThread = [
438
- ChannelType.PublicThread,
439
- ChannelType.PrivateThread,
440
- ChannelType.AnnouncementThread,
441
- ].includes(channel.type);
442
- if (isThread) {
443
- const thread = channel;
444
- discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
445
- // Only respond in threads kimaki knows about (has a session row in DB),
446
- // where the bot is explicitly @mentioned, or where the bot created the
447
- // thread itself (e.g. /new-worktree, /fork, kimaki send). This prevents
448
- // the bot from hijacking user-created threads in project channels while
449
- // still responding to bot-created threads that may not yet have a session
450
- // row with a non-empty session_id (createPendingWorkspace sets ''). (GitHub #84)
451
- const hasExistingSession = await getThreadSession(thread.id);
452
- const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
453
- const botCreatedThread = discordClient.user && thread.ownerId === discordClient.user.id;
454
- if (!hasExistingSession &&
455
- !botMentioned &&
456
- !isCliInjectedPrompt &&
457
- !botCreatedThread) {
458
- discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
376
+ // Check mention mode BEFORE permission check for text channels.
377
+ // When mention mode is enabled, users without Kimaki role can message
378
+ // without getting a permission error - we just silently ignore.
379
+ const channel = message.channel;
380
+ // In text channels, messages starting with a mention to another user
381
+ // are fully ignored before any permission or mention-mode checks.
382
+ // This prevents permission-error replies for user-to-user conversation.
383
+ if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
459
384
  return;
460
385
  }
461
- // Context-only messages (user-to-user replies) can't be stored without
462
- // an existing session. Skip early to avoid creating a runtime or running
463
- // preprocessing for nothing.
464
- if (isLeadingMentionToOtherUser && !hasExistingSession) {
465
- return;
386
+ if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
387
+ const mentionModeEnabled = await getChannelMentionMode(channel.id);
388
+ if (mentionModeEnabled) {
389
+ const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
390
+ const isShellCommand = message.content?.startsWith('!');
391
+ if (!botMentioned && !isShellCommand) {
392
+ voiceLogger.log(`[IGNORED] Mention mode enabled, bot not mentioned`);
393
+ return;
394
+ }
395
+ }
466
396
  }
467
- const parent = thread.parent;
468
- let projectDirectory;
469
- if (parent) {
470
- const channelConfig = await getChannelDirectory(parent.id);
471
- if (channelConfig) {
472
- projectDirectory = channelConfig.directory;
397
+ // Multi-machine routing: check channel ownership before permission
398
+ // checks so we don't send permission-denial replies in channels owned
399
+ // by another machine. Resolve the "owning" channel: for threads, check
400
+ // the parent channel; for text channels, check the channel itself.
401
+ if (!isCliInjectedPrompt && message.guild) {
402
+ const channel = message.channel;
403
+ let owningChannelId;
404
+ if ([
405
+ ChannelType.PublicThread,
406
+ ChannelType.PrivateThread,
407
+ ChannelType.AnnouncementThread,
408
+ ].includes(channel.type)) {
409
+ const thread = channel;
410
+ owningChannelId = thread.parent?.id || thread.parentId || undefined;
411
+ }
412
+ else {
413
+ owningChannelId = channel.id;
414
+ }
415
+ if (owningChannelId) {
416
+ const channelConfig = await getChannelDirectory(owningChannelId);
417
+ if (!channelConfig) {
418
+ voiceLogger.log(`[IGNORED] Channel ${owningChannelId} has no project directory configured`);
419
+ return;
420
+ }
473
421
  }
474
422
  }
475
- // Check if this thread is a worktree thread.
476
- // When the runtime exists in memory, pending worktrees are handled by
477
- // the preprocess chain (messages queue behind the worktree promise).
478
- // After a bot restart the runtime is gone, so we must reject messages
479
- // for pending worktrees to avoid running in the base directory.
480
- // Check both thread_workspaces (new) and thread_worktrees (legacy)
481
- const worktreeInfo = await getThreadWorktreeOrWorkspace(thread.id);
482
- if (worktreeInfo) {
483
- if (worktreeInfo.status === 'pending' && !getRuntime(thread.id)) {
423
+ if (!isCliInjectedPrompt && message.guild) {
424
+ const member = await resolveGuildMessageMember(message);
425
+ if (!member) {
426
+ return;
427
+ }
428
+ if (hasNoKimakiRole(member)) {
429
+ await message.reply({
430
+ content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
431
+ flags: SILENT_MESSAGE_FLAGS,
432
+ });
433
+ return;
434
+ }
435
+ if (!hasKimakiBotPermission(member, message.guild)) {
484
436
  await message.reply({
485
- content: '⏳ Worktree is still being created. Please wait...',
437
+ content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
486
438
  flags: SILENT_MESSAGE_FLAGS,
487
439
  });
488
440
  return;
489
441
  }
490
- if (worktreeInfo.status === 'error') {
442
+ }
443
+ const isThread = [
444
+ ChannelType.PublicThread,
445
+ ChannelType.PrivateThread,
446
+ ChannelType.AnnouncementThread,
447
+ ].includes(channel.type);
448
+ if (isThread) {
449
+ const thread = channel;
450
+ discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
451
+ // Only respond in threads kimaki knows about (has a session row in DB),
452
+ // where the bot is explicitly @mentioned, or where the bot created the
453
+ // thread itself (e.g. /new-worktree, /fork, kimaki send). This prevents
454
+ // the bot from hijacking user-created threads in project channels while
455
+ // still responding to bot-created threads that may not yet have a session
456
+ // row with a non-empty session_id (createPendingWorkspace sets ''). (GitHub #84)
457
+ const hasExistingSession = await getThreadSession(thread.id);
458
+ const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
459
+ const botCreatedThread = discordClient.user && thread.ownerId === discordClient.user.id;
460
+ if (!hasExistingSession &&
461
+ !botMentioned &&
462
+ !isCliInjectedPrompt &&
463
+ !botCreatedThread) {
464
+ discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
465
+ return;
466
+ }
467
+ // Context-only messages (user-to-user replies) can't be stored without
468
+ // an existing session. Skip early to avoid creating a runtime or running
469
+ // preprocessing for nothing.
470
+ if (isLeadingMentionToOtherUser && !hasExistingSession) {
471
+ return;
472
+ }
473
+ const parent = thread.parent;
474
+ let projectDirectory;
475
+ if (parent) {
476
+ const channelConfig = await getChannelDirectory(parent.id);
477
+ if (channelConfig) {
478
+ projectDirectory = channelConfig.directory;
479
+ }
480
+ }
481
+ // Check if this thread is a worktree thread.
482
+ // When the runtime exists in memory, pending worktrees are handled by
483
+ // the preprocess chain (messages queue behind the worktree promise).
484
+ // After a bot restart the runtime is gone, so we must reject messages
485
+ // for pending worktrees to avoid running in the base directory.
486
+ // Check both thread_workspaces (new) and thread_worktrees (legacy)
487
+ const worktreeInfo = await getThreadWorktreeOrWorkspace(thread.id);
488
+ if (worktreeInfo) {
489
+ if (worktreeInfo.status === 'pending' && !getRuntime(thread.id)) {
490
+ await message.reply({
491
+ content: '⏳ Worktree is still being created. Please wait...',
492
+ flags: SILENT_MESSAGE_FLAGS,
493
+ });
494
+ return;
495
+ }
496
+ if (worktreeInfo.status === 'error') {
497
+ await message.reply({
498
+ content: `❌ Worktree creation failed: ${(worktreeInfo.error_message || '').slice(0, 1900)}`,
499
+ flags: NOTIFY_MESSAGE_FLAGS,
500
+ });
501
+ return;
502
+ }
503
+ // Use original project directory for OpenCode server (session lives there)
504
+ // The worktree directory is passed via query.directory in prompt/command calls
505
+ if (worktreeInfo.project_directory) {
506
+ projectDirectory = worktreeInfo.project_directory;
507
+ discordLogger.log(`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.workspace_directory})`);
508
+ }
509
+ }
510
+ if (projectDirectory && !fs.existsSync(projectDirectory)) {
511
+ discordLogger.error(`Directory does not exist: ${projectDirectory}`);
491
512
  await message.reply({
492
- content: `❌ Worktree creation failed: ${(worktreeInfo.error_message || '').slice(0, 1900)}`,
513
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
493
514
  flags: NOTIFY_MESSAGE_FLAGS,
494
515
  });
495
516
  return;
496
517
  }
497
- // Use original project directory for OpenCode server (session lives there)
498
- // The worktree directory is passed via query.directory in prompt/command calls
499
- if (worktreeInfo.project_directory) {
500
- projectDirectory = worktreeInfo.project_directory;
501
- discordLogger.log(`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.workspace_directory})`);
518
+ // ! prefix runs a shell command instead of starting/continuing a session.
519
+ // Use worktree directory if available, so commands run in the worktree cwd.
520
+ // Skip shell commands while worktree is pending — they'd run in the base dir.
521
+ if (message.content?.startsWith('!') &&
522
+ projectDirectory &&
523
+ worktreeInfo?.status !== 'pending') {
524
+ const shellCmd = message.content.slice(1).trim();
525
+ if (shellCmd) {
526
+ threadIngressSlot?.release();
527
+ const shellDir = worktreeInfo?.status === 'ready' &&
528
+ worktreeInfo.workspace_directory
529
+ ? worktreeInfo.workspace_directory
530
+ : projectDirectory;
531
+ const loadingReply = await message.reply({
532
+ content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
533
+ });
534
+ const result = await runShellCommand({
535
+ command: shellCmd,
536
+ directory: shellDir,
537
+ });
538
+ await loadingReply.edit({ content: result });
539
+ return;
540
+ }
502
541
  }
503
- }
504
- if (projectDirectory && !fs.existsSync(projectDirectory)) {
505
- discordLogger.error(`Directory does not exist: ${projectDirectory}`);
506
- await message.reply({
507
- content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
508
- flags: NOTIFY_MESSAGE_FLAGS,
509
- });
510
- return;
511
- }
512
- // ! prefix runs a shell command instead of starting/continuing a session.
513
- // Use worktree directory if available, so commands run in the worktree cwd.
514
- // Skip shell commands while worktree is pending — they'd run in the base dir.
515
- if (message.content?.startsWith('!') &&
516
- projectDirectory &&
517
- worktreeInfo?.status !== 'pending') {
518
- const shellCmd = message.content.slice(1).trim();
519
- if (shellCmd) {
520
- const shellDir = worktreeInfo?.status === 'ready' &&
542
+ // `. btw` suffix mirrors /btw for fast side-question forks.
543
+ // Works like queue: just the word "btw" at the end after punctuation
544
+ // or newline. The whole message (minus the suffix) becomes the fork prompt.
545
+ const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
546
+ ? extractBtwSuffix(message.content || '')
547
+ : null;
548
+ if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
549
+ threadIngressSlot?.release();
550
+ const btwSdkDir = worktreeInfo?.status === 'ready' &&
521
551
  worktreeInfo.workspace_directory
522
552
  ? worktreeInfo.workspace_directory
523
553
  : projectDirectory;
524
- const loadingReply = await message.reply({
525
- content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
554
+ const result = await forkSessionToBtwThread({
555
+ sourceThread: thread,
556
+ projectDirectory,
557
+ sdkDirectory: btwSdkDir,
558
+ prompt: btwResult.prompt,
559
+ userId: message.author.id,
560
+ username: message.member?.displayName || message.author.displayName,
561
+ appId: currentAppId,
526
562
  });
527
- const result = await runShellCommand({
528
- command: shellCmd,
529
- directory: shellDir,
563
+ if (result instanceof Error) {
564
+ await message.reply({
565
+ content: result.message,
566
+ flags: SILENT_MESSAGE_FLAGS,
567
+ });
568
+ return;
569
+ }
570
+ await message.reply({
571
+ content: `Session forked! Continue in ${result.thread.toString()}`,
572
+ flags: SILENT_MESSAGE_FLAGS,
530
573
  });
531
- await loadingReply.edit({ content: result });
532
574
  return;
533
575
  }
534
- }
535
- // `. btw` suffix mirrors /btw for fast side-question forks.
536
- // Works like queue: just the word "btw" at the end after punctuation
537
- // or newline. The whole message (minus the suffix) becomes the fork prompt.
538
- const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
539
- ? extractBtwSuffix(message.content || '')
540
- : null;
541
- if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
542
- const btwSdkDir = worktreeInfo?.status === 'ready' &&
543
- worktreeInfo.workspace_directory
544
- ? worktreeInfo.workspace_directory
545
- : projectDirectory;
546
- const result = await forkSessionToBtwThread({
547
- sourceThread: thread,
548
- projectDirectory,
549
- sdkDirectory: btwSdkDir,
550
- prompt: btwResult.prompt,
551
- userId: message.author.id,
552
- username: message.member?.displayName || message.author.displayName,
553
- appId: currentAppId,
576
+ const hasVoiceAttachment = message.attachments.some((attachment) => {
577
+ return isVoiceAttachment(attachment);
554
578
  });
555
- if (result instanceof Error) {
579
+ if (!projectDirectory) {
580
+ discordLogger.log(`Cannot process message: no project directory for thread ${thread.id}`);
581
+ return;
582
+ }
583
+ if (isMissingReadableMessageContent(message)) {
556
584
  await message.reply({
557
- content: result.message,
585
+ content: MISSING_MESSAGE_CONTENT_REPLY,
558
586
  flags: SILENT_MESSAGE_FLAGS,
559
587
  });
560
588
  return;
561
589
  }
562
- await message.reply({
563
- content: `Session forked! Continue in ${result.thread.toString()}`,
564
- flags: SILENT_MESSAGE_FLAGS,
565
- });
566
- return;
567
- }
568
- const hasVoiceAttachment = message.attachments.some((attachment) => {
569
- return isVoiceAttachment(attachment);
570
- });
571
- if (!projectDirectory) {
572
- discordLogger.log(`Cannot process message: no project directory for thread ${thread.id}`);
573
- return;
574
- }
575
- if (isMissingReadableMessageContent(message)) {
576
- await message.reply({
577
- content: MISSING_MESSAGE_CONTENT_REPLY,
578
- flags: SILENT_MESSAGE_FLAGS,
590
+ const resolvedProjectDir = projectDirectory;
591
+ const sdkDir = worktreeInfo?.status === 'ready' &&
592
+ worktreeInfo.workspace_directory
593
+ ? worktreeInfo.workspace_directory
594
+ : resolvedProjectDir;
595
+ const runtime = getOrCreateRuntime({
596
+ threadId: thread.id,
597
+ thread,
598
+ projectDirectory: resolvedProjectDir,
599
+ sdkDirectory: sdkDir,
600
+ channelId: parent?.id || undefined,
601
+ appId: currentAppId,
579
602
  });
580
- return;
581
- }
582
- const resolvedProjectDir = projectDirectory;
583
- const sdkDir = worktreeInfo?.status === 'ready' &&
584
- worktreeInfo.workspace_directory
585
- ? worktreeInfo.workspace_directory
586
- : resolvedProjectDir;
587
- const runtime = getOrCreateRuntime({
588
- threadId: thread.id,
589
- thread,
590
- projectDirectory: resolvedProjectDir,
591
- sdkDirectory: sdkDir,
592
- channelId: parent?.id || undefined,
593
- appId: currentAppId,
594
- });
595
- // Cancel interactive UI when a real user sends a message.
596
- // Context-only messages (user-to-user replies) should not interrupt
597
- // the active run or dismiss pending UI.
598
- if (!message.author.bot && !isCliInjectedPrompt && !isLeadingMentionToOtherUser) {
599
- cancelPendingActionButtons(thread.id);
600
- cancelHtmlActionsForThread(thread.id);
601
- const dismissedPermission = await cancelPendingPermission(thread.id);
602
- if (dismissedPermission) {
603
- await runtime.abortActiveRunAndWait({
604
- reason: 'user sent a new message while permission was pending',
605
- });
603
+ // Cancel interactive UI when a real user sends a message.
604
+ // Context-only messages (user-to-user replies) should not interrupt
605
+ // the active run or dismiss pending UI.
606
+ const dismissSourceUi = async () => {
607
+ if (message.author.bot || isCliInjectedPrompt || isLeadingMentionToOtherUser)
608
+ return;
609
+ cancelPendingActionButtons(thread.id);
610
+ cancelHtmlActionsForThread(thread.id);
611
+ const dismissedPermission = await cancelPendingPermission(thread.id);
612
+ if (dismissedPermission) {
613
+ await runtime.abortActiveRunAndWait({
614
+ reason: 'user sent a new message while permission was pending',
615
+ });
616
+ }
617
+ const dismissedQuestion = hasPendingQuestionForThread(thread.id);
618
+ if (dismissedQuestion) {
619
+ await cancelPendingQuestion(thread.id);
620
+ await runtime.abortActiveRunAndWait({
621
+ reason: 'user sent a new message while question was pending',
622
+ });
623
+ }
624
+ void cancelPendingFileUpload(thread.id);
625
+ };
626
+ if (!hasVoiceAttachment) {
627
+ await dismissSourceUi();
606
628
  }
607
- const dismissedQuestion = hasPendingQuestionForThread(thread.id);
608
- if (dismissedQuestion) {
609
- await cancelPendingQuestion(thread.id);
610
- await runtime.abortActiveRunAndWait({
611
- reason: 'user sent a new message while question was pending',
612
- });
629
+ // A sleep wake only becomes a turn if it can still claim its own row.
630
+ // The claim fails when the user cancelled the sleep while the wake was
631
+ // being posted, so the superseded wake is dropped instead of starting a
632
+ // turn the user already replaced. Cancellation for every other kind of
633
+ // message happens inside runtime.enqueueIncoming().
634
+ const isSleepWake = Boolean(promptMarker?.sleepWake);
635
+ if (isSleepWake) {
636
+ const claimedWake = promptMarker?.sleepId
637
+ ? await consumeSessionSleepWake({ deliveryId: promptMarker.sleepId })
638
+ : false;
639
+ if (!claimedWake) {
640
+ discordLogger.log(`[SLEEP] ignoring superseded wake in thread ${thread.id}`);
641
+ return;
642
+ }
643
+ }
644
+ // Expensive pre-processing (voice transcription, context fetch,
645
+ // attachment download) runs inside the runtime's serialized
646
+ // preprocess chain, preserving Discord arrival order without
647
+ // blocking SSE event handling in dispatchAction.
648
+ const enqueueResult = await runtime.enqueueIncoming({
649
+ prompt: '',
650
+ userId: cliInjectedUserId || message.author.id,
651
+ username: cliInjectedUsername ||
652
+ message.member?.displayName ||
653
+ message.author.displayName,
654
+ sourceMessageId: message.id,
655
+ sourceThreadId: thread.id,
656
+ appId: currentAppId,
657
+ agent: cliInjectedAgent,
658
+ model: cliInjectedModel,
659
+ permissions: cliInjectedPermissions,
660
+ injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
661
+ parentSessionId: cliInjectedParentSessionId,
662
+ isSleepWake: isSleepWake || undefined,
663
+ noReply: isLeadingMentionToOtherUser || undefined,
664
+ sessionStartSource: sessionStartSource
665
+ ? {
666
+ scheduleKind: sessionStartSource.scheduleKind,
667
+ scheduledTaskId: sessionStartSource.scheduledTaskId,
668
+ scheduledTaskRunId: sessionStartSource.scheduledTaskRunId,
669
+ }
670
+ : undefined,
671
+ preprocess: async () => {
672
+ const result = await preprocessExistingThreadMessage({
673
+ message,
674
+ thread,
675
+ projectDirectory: resolvedProjectDir,
676
+ channelId: parent?.id || undefined,
677
+ isCliInjected: isCliInjectedPrompt,
678
+ hasVoiceAttachment,
679
+ appId: currentAppId,
680
+ });
681
+ // Routing must finish before touching source UI. This chain is separate
682
+ // from dispatchAction, so abort waiting does not block session events.
683
+ if (hasVoiceAttachment && !result.skip) {
684
+ await dismissSourceUi();
685
+ }
686
+ return result;
687
+ },
688
+ });
689
+ // Notify when a voice message was queued instead of sent immediately
690
+ if (enqueueResult.queued && enqueueResult.position) {
691
+ await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`);
613
692
  }
614
- void cancelPendingFileUpload(thread.id);
615
693
  }
616
- // Expensive pre-processing (voice transcription, context fetch,
617
- // attachment download) runs inside the runtime's serialized
618
- // preprocess chain, preserving Discord arrival order without
619
- // blocking SSE event handling in dispatchAction.
620
- const enqueueResult = await runtime.enqueueIncoming({
621
- prompt: '',
622
- userId: cliInjectedUserId || message.author.id,
623
- username: cliInjectedUsername ||
624
- message.member?.displayName ||
625
- message.author.displayName,
626
- sourceMessageId: message.id,
627
- sourceThreadId: thread.id,
628
- appId: currentAppId,
629
- agent: cliInjectedAgent,
630
- model: cliInjectedModel,
631
- permissions: cliInjectedPermissions,
632
- injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
633
- parentSessionId: cliInjectedParentSessionId,
634
- noReply: isLeadingMentionToOtherUser || undefined,
635
- sessionStartSource: sessionStartSource
636
- ? {
637
- scheduleKind: sessionStartSource.scheduleKind,
638
- scheduledTaskId: sessionStartSource.scheduledTaskId,
639
- scheduledTaskRunId: sessionStartSource.scheduledTaskRunId,
694
+ if (channel.type === ChannelType.GuildText) {
695
+ // `kimaki send` posts a starter message with a `start` embed marker,
696
+ // then creates the thread via REST. The ThreadCreate handler picks up
697
+ // that thread and starts the session. If we don't skip here, this
698
+ // handler races the CLI to call startThread() on the same message,
699
+ // causing DiscordAPIError[160004] "A thread has already been created
700
+ // for this message".
701
+ if (promptMarker?.start) {
702
+ return;
703
+ }
704
+ voiceLogger.log(`[GUILD_TEXT] Message in text channel #${channel.name} (${channel.id})`);
705
+ const channelConfig = await getChannelDirectory(channel.id);
706
+ if (!channelConfig) {
707
+ const botMentioned = Boolean(discordClient.user && message.mentions.has(discordClient.user.id));
708
+ if (botMentioned) {
709
+ // TODO: Consider creating/using a session for any text channel when Kimaki is
710
+ // explicitly @mentioned, so the bot can answer quick questions even before
711
+ // the channel is linked to a project.
712
+ await message.reply({
713
+ content: 'This channel is not connected to an OpenCode project.\nSend your message in a project channel, or use `/add-project` for an existing project, or `/create-new-project` to make a new one.',
714
+ flags: SILENT_MESSAGE_FLAGS,
715
+ });
716
+ return;
640
717
  }
641
- : undefined,
642
- preprocess: () => {
643
- return preprocessExistingThreadMessage({
644
- message,
645
- thread,
646
- projectDirectory: resolvedProjectDir,
647
- channelId: parent?.id || undefined,
648
- isCliInjected: isCliInjectedPrompt,
649
- hasVoiceAttachment,
650
- appId: currentAppId,
718
+ voiceLogger.log(`[IGNORED] Channel #${channel.name} has no project directory configured`);
719
+ return;
720
+ }
721
+ const projectDirectory = channelConfig.directory;
722
+ // Note: Mention mode is checked early in the handler (before permission check)
723
+ // to avoid sending permission errors to users who just didn't @mention the bot.
724
+ discordLogger.log(`DIRECTORY: Found kimaki.directory: ${projectDirectory}`);
725
+ if (!fs.existsSync(projectDirectory)) {
726
+ discordLogger.error(`Directory does not exist: ${projectDirectory}`);
727
+ await message.reply({
728
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
729
+ flags: NOTIFY_MESSAGE_FLAGS,
651
730
  });
652
- },
653
- });
654
- // Notify when a voice message was queued instead of sent immediately
655
- if (enqueueResult.queued && enqueueResult.position) {
656
- await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`);
657
- }
658
- }
659
- if (channel.type === ChannelType.GuildText) {
660
- // `kimaki send` posts a starter message with a `start` embed marker,
661
- // then creates the thread via REST. The ThreadCreate handler picks up
662
- // that thread and starts the session. If we don't skip here, this
663
- // handler races the CLI to call startThread() on the same message,
664
- // causing DiscordAPIError[160004] "A thread has already been created
665
- // for this message".
666
- if (promptMarker?.start) {
667
- return;
668
- }
669
- voiceLogger.log(`[GUILD_TEXT] Message in text channel #${channel.name} (${channel.id})`);
670
- const channelConfig = await getChannelDirectory(channel.id);
671
- if (!channelConfig) {
672
- const botMentioned = Boolean(discordClient.user && message.mentions.has(discordClient.user.id));
673
- if (botMentioned) {
674
- // TODO: Consider creating/using a session for any text channel when Kimaki is
675
- // explicitly @mentioned, so the bot can answer quick questions even before
676
- // the channel is linked to a project.
731
+ return;
732
+ }
733
+ if (isMissingReadableMessageContent(message)) {
677
734
  await message.reply({
678
- content: 'This channel is not connected to an OpenCode project.\nSend your message in a project channel, or use `/add-project` for an existing project, or `/create-new-project` to make a new one.',
735
+ content: MISSING_MESSAGE_CONTENT_REPLY,
679
736
  flags: SILENT_MESSAGE_FLAGS,
680
737
  });
681
738
  return;
682
739
  }
683
- voiceLogger.log(`[IGNORED] Channel #${channel.name} has no project directory configured`);
684
- return;
685
- }
686
- const projectDirectory = channelConfig.directory;
687
- // Note: Mention mode is checked early in the handler (before permission check)
688
- // to avoid sending permission errors to users who just didn't @mention the bot.
689
- discordLogger.log(`DIRECTORY: Found kimaki.directory: ${projectDirectory}`);
690
- if (!fs.existsSync(projectDirectory)) {
691
- discordLogger.error(`Directory does not exist: ${projectDirectory}`);
692
- await message.reply({
693
- content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
694
- flags: NOTIFY_MESSAGE_FLAGS,
740
+ // ! prefix runs a shell command instead of starting a session
741
+ if (message.content?.startsWith('!')) {
742
+ const shellCmd = message.content.slice(1).trim();
743
+ if (shellCmd) {
744
+ threadIngressSlot?.release();
745
+ const loadingReply = await message.reply({
746
+ content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
747
+ });
748
+ const result = await runShellCommand({
749
+ command: shellCmd,
750
+ directory: projectDirectory,
751
+ });
752
+ await loadingReply.edit({ content: result });
753
+ return;
754
+ }
755
+ }
756
+ const hasVoice = message.attachments.some((attachment) => {
757
+ return isVoiceAttachment(attachment);
695
758
  });
696
- return;
697
- }
698
- if (isMissingReadableMessageContent(message)) {
699
- await message.reply({
700
- content: MISSING_MESSAGE_CONTENT_REPLY,
701
- flags: SILENT_MESSAGE_FLAGS,
759
+ const baseThreadName = hasVoice
760
+ ? 'Voice Message'
761
+ : stripMentions(message.content || '')
762
+ .replace(/\s+/g, ' ')
763
+ .trim() || 'kimaki thread';
764
+ // Check if worktrees should be enabled (CLI flag OR channel setting).
765
+ // Only create worktrees from the configured project directory when that
766
+ // directory is itself the git root. If the user registered a non-git
767
+ // workspace folder under a larger repo, git would create the worktree
768
+ // from the parent repo and strand follow-up messages on failure.
769
+ const wantsWorktrees = useWorktrees || (await getChannelWorktreesEnabled(channel.id));
770
+ const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
771
+ if (wantsWorktrees && !shouldUseWorktrees) {
772
+ discordLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
773
+ }
774
+ // Add worktree prefix if worktrees are enabled
775
+ const threadName = shouldUseWorktrees
776
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
777
+ : baseThreadName;
778
+ const thread = await message.startThread({
779
+ name: threadName.slice(0, 80),
780
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
781
+ reason: 'Start Claude session',
702
782
  });
703
- return;
704
- }
705
- // ! prefix runs a shell command instead of starting a session
706
- if (message.content?.startsWith('!')) {
707
- const shellCmd = message.content.slice(1).trim();
708
- if (shellCmd) {
709
- const loadingReply = await message.reply({
710
- content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
711
- });
712
- const result = await runShellCommand({
713
- command: shellCmd,
714
- directory: projectDirectory,
783
+ // Add user to thread so it appears in their sidebar
784
+ await thread.members.add(message.author.id);
785
+ discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
786
+ // Create runtime immediately so follow-up messages queue naturally
787
+ // via the preprocess chain instead of being rejected with "please wait".
788
+ // When worktrees are enabled, the worktree promise runs concurrently
789
+ // and the first message's preprocess callback awaits it before resolving.
790
+ let worktreePromise;
791
+ if (shouldUseWorktrees) {
792
+ // Auto-derived from thread name -- compress long slugs so the
793
+ // folder path stays short and the agent doesn't reuse old worktrees.
794
+ const worktreeName = formatAutoWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
795
+ discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
796
+ const worktreeStatusMessage = await thread
797
+ .send({
798
+ content: worktreeCreatingMessage(worktreeName),
799
+ flags: SILENT_MESSAGE_FLAGS,
800
+ })
801
+ .catch(() => undefined);
802
+ worktreePromise = createWorktreeInBackground({
803
+ thread,
804
+ starterMessage: worktreeStatusMessage,
805
+ worktreeName,
806
+ projectDirectory,
807
+ rest: discordClient.rest,
715
808
  });
716
- await loadingReply.edit({ content: result });
717
- return;
718
809
  }
719
- }
720
- const hasVoice = message.attachments.some((attachment) => {
721
- return isVoiceAttachment(attachment);
722
- });
723
- const baseThreadName = hasVoice
724
- ? 'Voice Message'
725
- : stripMentions(message.content || '')
726
- .replace(/\s+/g, ' ')
727
- .trim() || 'kimaki thread';
728
- // Check if worktrees should be enabled (CLI flag OR channel setting).
729
- // Only create worktrees from the configured project directory when that
730
- // directory is itself the git root. If the user registered a non-git
731
- // workspace folder under a larger repo, git would create the worktree
732
- // from the parent repo and strand follow-up messages on failure.
733
- const wantsWorktrees = useWorktrees || (await getChannelWorktreesEnabled(channel.id));
734
- const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
735
- if (wantsWorktrees && !shouldUseWorktrees) {
736
- discordLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
737
- }
738
- // Add worktree prefix if worktrees are enabled
739
- const threadName = shouldUseWorktrees
740
- ? `${WORKTREE_PREFIX}${baseThreadName}`
741
- : baseThreadName;
742
- const thread = await message.startThread({
743
- name: threadName.slice(0, 80),
744
- autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
745
- reason: 'Start Claude session',
746
- });
747
- // Add user to thread so it appears in their sidebar
748
- await thread.members.add(message.author.id);
749
- discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
750
- // Create runtime immediately so follow-up messages queue naturally
751
- // via the preprocess chain instead of being rejected with "please wait".
752
- // When worktrees are enabled, the worktree promise runs concurrently
753
- // and the first message's preprocess callback awaits it before resolving.
754
- let worktreePromise;
755
- if (shouldUseWorktrees) {
756
- // Auto-derived from thread name -- compress long slugs so the
757
- // folder path stays short and the agent doesn't reuse old worktrees.
758
- const worktreeName = formatAutoWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
759
- discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
760
- const worktreeStatusMessage = await thread
761
- .send({
762
- content: worktreeCreatingMessage(worktreeName),
763
- flags: SILENT_MESSAGE_FLAGS,
764
- })
765
- .catch(() => undefined);
766
- worktreePromise = createWorktreeInBackground({
810
+ const worktreeResult = worktreePromise ? await worktreePromise : projectDirectory;
811
+ if (worktreeResult instanceof Error)
812
+ return;
813
+ const sessionDirectory = worktreeResult;
814
+ const channelRuntime = getOrCreateRuntime({
815
+ threadId: thread.id,
767
816
  thread,
768
- starterMessage: worktreeStatusMessage,
769
- worktreeName,
770
817
  projectDirectory,
771
- rest: discordClient.rest,
818
+ sdkDirectory: sessionDirectory,
819
+ channelId: channel.id,
820
+ appId: currentAppId,
821
+ });
822
+ await channelRuntime.enqueueIncoming({
823
+ prompt: '',
824
+ userId: message.author.id,
825
+ username: message.member?.displayName || message.author.displayName,
826
+ sourceMessageId: message.id,
827
+ sourceThreadId: thread.id,
828
+ appId: currentAppId,
829
+ preprocess: async () => {
830
+ return preprocessNewThreadMessage({
831
+ message,
832
+ thread,
833
+ projectDirectory: sessionDirectory,
834
+ hasVoiceAttachment: hasVoice,
835
+ appId: currentAppId,
836
+ });
837
+ },
772
838
  });
773
839
  }
774
- const sessionDirectory = await (async () => {
775
- if (!worktreePromise) {
776
- return projectDirectory;
777
- }
778
- const result = await worktreePromise;
779
- if (result instanceof Error)
780
- return projectDirectory;
781
- return result;
782
- })();
783
- const channelRuntime = getOrCreateRuntime({
784
- threadId: thread.id,
785
- thread,
786
- projectDirectory,
787
- sdkDirectory: sessionDirectory,
788
- channelId: channel.id,
789
- appId: currentAppId,
790
- });
791
- await channelRuntime.enqueueIncoming({
792
- prompt: '',
793
- userId: message.author.id,
794
- username: message.member?.displayName || message.author.displayName,
795
- sourceMessageId: message.id,
796
- sourceThreadId: thread.id,
797
- appId: currentAppId,
798
- preprocess: async () => {
799
- return preprocessNewThreadMessage({
800
- message,
801
- thread,
802
- projectDirectory: sessionDirectory,
803
- hasVoiceAttachment: hasVoice,
804
- appId: currentAppId,
805
- });
806
- },
807
- });
808
- }
809
- else {
810
- // discordLogger.log(`Channel type ${channel.type} is not supported`)
811
- }
812
- }
813
- catch (error) {
814
- voiceLogger.error('Discord handler error:', error);
815
- void notifyError(error, 'MessageCreate handler error');
816
- try {
817
- const errMsg = (error instanceof Error ? error.message : String(error)).slice(0, 1900);
818
- await message.reply({
819
- content: `Error: ${errMsg}`,
820
- flags: NOTIFY_MESSAGE_FLAGS,
821
- });
840
+ else {
841
+ // discordLogger.log(`Channel type ${channel.type} is not supported`)
842
+ }
822
843
  }
823
- catch (sendError) {
824
- voiceLogger.error('Discord handler error (fallback):', sendError instanceof Error ? sendError.message : String(sendError));
844
+ catch (error) {
845
+ voiceLogger.error('Discord handler error:', error);
846
+ void notifyError(error, 'MessageCreate handler error');
847
+ try {
848
+ const errMsg = (error instanceof Error ? error.message : String(error)).slice(0, 1900);
849
+ await message.reply({
850
+ content: `Error: ${errMsg}`,
851
+ flags: NOTIFY_MESSAGE_FLAGS,
852
+ });
853
+ }
854
+ catch (sendError) {
855
+ voiceLogger.error('Discord handler error (fallback):', sendError instanceof Error ? sendError.message : String(sendError));
856
+ }
825
857
  }
826
- }
858
+ });
827
859
  });
828
860
  // Handle user message edits to update queued messages.
829
861
  // When a user edits a message that is still waiting in kimaki's local queue,
@@ -869,11 +901,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
869
901
  const displayName = message.member?.displayName ?? message.author.displayName;
870
902
  if (result.removed) {
871
903
  discordLogger.log(`[MESSAGE_EDIT] Removed queued message ${message.id} in thread ${channel.id}`);
872
- await sendThreadMessage(channel, `⬦ **${displayName}** removed message from queue`);
904
+ await sendThreadMessage(channel, `${STATUS_PREFIX}**${displayName}** removed message from queue`);
873
905
  }
874
906
  else {
875
907
  discordLogger.log(`[MESSAGE_EDIT] Updated queued message ${message.id} in thread ${channel.id}`);
876
- await sendThreadMessage(channel, `⬦ **${displayName}** edited queued message`);
908
+ await sendThreadMessage(channel, `${STATUS_PREFIX}**${displayName}** edited queued message`);
877
909
  }
878
910
  }
879
911
  }
@@ -896,7 +928,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
896
928
  if (!removed)
897
929
  return;
898
930
  discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
899
- await sendThreadMessage(channel, `⬦ **${removed.username}** removed message from queue`);
931
+ await sendThreadMessage(channel, `${STATUS_PREFIX}**${removed.username}** removed message from queue`);
900
932
  }
901
933
  catch (error) {
902
934
  discordLogger.error('Error handling message delete:', error instanceof Error ? error.stack : String(error));
@@ -1055,18 +1087,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1055
1087
  }
1056
1088
  discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
1057
1089
  const botThreadStartSource = parseSessionStartSourceFromMarker(marker);
1058
- const sessionDirectory = await (async () => {
1059
- if (cwdDirectory) {
1060
- return cwdDirectory;
1061
- }
1062
- if (!worktreePromise) {
1063
- return projectDirectory;
1064
- }
1065
- const result = await worktreePromise;
1066
- if (result instanceof Error)
1067
- return projectDirectory;
1068
- return result;
1069
- })();
1090
+ const worktreeResult = worktreePromise ? await worktreePromise : undefined;
1091
+ if (worktreeResult instanceof Error)
1092
+ return;
1093
+ const sessionDirectory = cwdDirectory ?? worktreeResult ?? projectDirectory;
1070
1094
  const runtime = getOrCreateRuntime({
1071
1095
  threadId: thread.id,
1072
1096
  thread,
@@ -1151,6 +1175,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1151
1175
  await discordClient.login(token);
1152
1176
  }
1153
1177
  startHeapMonitor();
1178
+ startStdinCpuProfListener();
1154
1179
  const stopTaskRunner = startTaskRunner({ token });
1155
1180
  const stopRuntimeIdleSweeper = startRuntimeIdleSweeper();
1156
1181
  // Prevent discord.js from permanently killing the REST token on 401.
@@ -1175,6 +1200,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1175
1200
  }
1176
1201
  global.shuttingDown = true;
1177
1202
  try {
1203
+ stopStdinCpuProfListener();
1204
+ const flushed = await flushCpuProfiling();
1205
+ if (flushed instanceof Error) {
1206
+ discordLogger.warn('Failed to flush CPU profile on shutdown:', flushed.message);
1207
+ }
1178
1208
  await stopRuntimeIdleSweeper();
1179
1209
  await stopTaskRunner();
1180
1210
  await flushDebouncedProcessCallbacks().catch((error) => {
@@ -1244,13 +1274,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1244
1274
  discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
1245
1275
  });
1246
1276
  });
1247
- // Self-restart: exit with code 1 so the bin.ts wrapper restarts us with
1248
- // exponential backoff and crash-loop detection. When running without the
1249
- // wrapper (e.g. `tsx src/cli.ts`), the process just exits and the user
1250
- // sees the non-zero exit code — they should use `tsx src/bin.ts` instead
1251
- // for auto-restart support. The previous detached-spawn fallback was
1252
- // unreliable: process.exit(0) could kill the child before it started,
1253
- // there was no backoff between restarts, and spawn failures were silent.
1277
+ // Self-restart: die so the bin.ts wrapper restarts us with exponential
1278
+ // backoff and crash-loop detection. process.exit() can hang joining native
1279
+ // worker threads after Discord gateway failures, so SIGKILL instead.
1280
+ // When running without the wrapper (e.g. `tsx src/cli.ts`), the process
1281
+ // just dies — use `tsx src/bin.ts` for auto-restart support.
1254
1282
  let selfRestarting = false;
1255
1283
  async function selfRestart(reason) {
1256
1284
  if (selfRestarting) {
@@ -1259,6 +1287,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1259
1287
  }
1260
1288
  selfRestarting = true;
1261
1289
  discordLogger.log(`Self-restarting (reason: ${reason})...`);
1290
+ setTimeout(() => process.kill(process.pid, 'SIGKILL'), 15_000);
1262
1291
  try {
1263
1292
  await handleShutdown(reason, { skipExit: true });
1264
1293
  }
@@ -1268,7 +1297,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1268
1297
  if (!process.env.__KIMAKI_CHILD) {
1269
1298
  discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
1270
1299
  }
1271
- process.exit(1);
1300
+ process.kill(process.pid, 'SIGKILL');
1272
1301
  }
1273
1302
  process.on('SIGUSR2', () => {
1274
1303
  discordLogger.log('Received SIGUSR2, restarting after cleanup...');