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
@@ -0,0 +1,155 @@
1
+ // Verify startup default channels require local self-hosted guild configuration.
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { afterEach, beforeEach, expect, test } from 'vitest'
5
+ import { ChannelType, Client, Events, GatewayIntentBits } from 'discord.js'
6
+ import { DigitalDiscord } from 'discord-digital-twin/src'
7
+ import { setDataDir } from './config.js'
8
+ import { closeDb } from './db.js'
9
+ import { findChannelsByDirectory, initDatabase, setChannelDirectory } from './database.js'
10
+ import { createProjectChannels } from './channel-management.js'
11
+ import { ensureDefaultChannelsWithWelcome } from './cli-runner.js'
12
+ import { registerInteractionHandler } from './interaction-handler.js'
13
+
14
+ const trustedGuildId = '100000000000000111'
15
+ const otherGuildId = '100000000000000222'
16
+ const projectChannelId = '100000000000000333'
17
+ let directory: string
18
+ let discord: DigitalDiscord
19
+ let client: Client
20
+
21
+ beforeEach(async () => {
22
+ const root = path.resolve('tmp/default-channel-provisioning')
23
+ fs.mkdirSync(root, { recursive: true })
24
+ directory = fs.mkdtempSync(path.join(root, 'run-'))
25
+ setDataDir(directory)
26
+ await closeDb()
27
+ await initDatabase()
28
+ discord = new DigitalDiscord({
29
+ dbUrl: `file:${path.join(directory, 'discord.db')}`,
30
+ users: [{ id: '100000000000000555', username: 'server-owner' }],
31
+ guilds: [
32
+ { id: trustedGuildId, name: 'Configured', ownerId: '100000000000000555', channels: [
33
+ { id: projectChannelId, name: 'project', type: ChannelType.GuildText },
34
+ ] },
35
+ { id: otherGuildId, name: 'Unconfigured', ownerId: '100000000000000555', channels: [
36
+ { name: 'project', type: ChannelType.GuildText, topic: directory },
37
+ ] },
38
+ ],
39
+ })
40
+ await discord.start()
41
+ client = new Client({
42
+ intents: [GatewayIntentBits.Guilds],
43
+ rest: { api: discord.restUrl, version: '10' },
44
+ })
45
+ const ready = new Promise<void>((resolve) => client.once(Events.ClientReady, () => resolve()))
46
+ await client.login(discord.botToken)
47
+ await ready
48
+ })
49
+
50
+ afterEach(async () => {
51
+ await client?.destroy()
52
+ await discord?.stop()
53
+ await closeDb()
54
+ fs.rmSync(directory, { recursive: true, force: true })
55
+ })
56
+
57
+ test('self-hosted startup does not configure any guild without a live local mapping', async () => {
58
+ await setChannelDirectory({
59
+ channelId: '100000000000000444',
60
+ directory,
61
+ channelType: 'text',
62
+ guildId: otherGuildId,
63
+ })
64
+ const before = await findChannelsByDirectory({})
65
+ const created = await ensureDefaultChannelsWithWelcome({
66
+ guilds: [...client.guilds.cache.values()], discordClient: client,
67
+ appId: discord.botUserId, isGatewayMode: false,
68
+ })
69
+ expect(created).toEqual([])
70
+ expect(await findChannelsByDirectory({})).toEqual(before)
71
+ for (const guild of client.guilds.cache.values()) {
72
+ expect((await guild.channels.fetch()).size).toBe(1)
73
+ }
74
+ })
75
+
76
+ test('setup commands require a local channel mapping even for the server owner', async () => {
77
+ registerInteractionHandler({ discordClient: client, appId: discord.botUserId })
78
+ const channel = discord.channel(projectChannelId)
79
+ // An invalid project name keeps the regression safe even when the guard fails.
80
+ const options = [{ name: 'name', type: 3, value: '---' }]
81
+ const ignored = await channel.user('100000000000000555').runSlashCommand({
82
+ name: 'create-new-project', options,
83
+ })
84
+ for (let attempt = 0; attempt < 10; attempt++) {
85
+ await new Promise((resolve) => setTimeout(resolve, 20))
86
+ expect((await channel.getInteractionResponse(ignored.id))?.acknowledged ?? false).toBe(false)
87
+ }
88
+ expect(await findChannelsByDirectory({})).toEqual([])
89
+
90
+ await setChannelDirectory({ channelId: projectChannelId, directory, channelType: 'text' })
91
+ const allowed = await channel.user('100000000000000555').runSlashCommand({
92
+ name: 'create-new-project', options,
93
+ })
94
+ await channel.waitForInteractionAck({ interactionId: allowed.id })
95
+ await channel.waitForBotReply()
96
+ expect(await channel.text()).toMatchInlineSnapshot(`
97
+ "--- from: assistant (TestBot)
98
+ Invalid project name"
99
+ `)
100
+ })
101
+
102
+ for (const setup of ['legacy mapping', 'explicit onboarding']) {
103
+ test(`self-hosted startup provisions only the configured guild after ${setup}`, async () => {
104
+ const guild = client.guilds.cache.get(trustedGuildId)!
105
+ if (setup === 'legacy mapping') {
106
+ await setChannelDirectory({ channelId: projectChannelId, directory, channelType: 'text' })
107
+ } else {
108
+ await createProjectChannels({ guild, projectDirectory: path.join(directory, 'project'), analyticsSource: 'onboarding' })
109
+ }
110
+ expect((await findChannelsByDirectory({}))[0]?.guild_id).toBeNull()
111
+ // Fetching actual guild channels must work even when startup cache is empty.
112
+ guild.channels.cache.clear()
113
+ const created = await ensureDefaultChannelsWithWelcome({
114
+ guilds: [...client.guilds.cache.values()], discordClient: client,
115
+ appId: discord.botUserId, isGatewayMode: false,
116
+ })
117
+ for (const channel of created) {
118
+ expect(await discord.channel(channel.id).text()).toMatchInlineSnapshot(`
119
+ "--- from: assistant (TestBot)
120
+ **Kimaki** lets you code from Discord. Send a message in any project channel and an AI agent edits code, runs commands, and searches your codebase — all on your machine.
121
+ **What you can do:**
122
+ - Use \`/add-project\` to create a Discord channel linked to one OpenCode project (git repo)
123
+ - Collaborate with teammates in the same session
124
+ - Upload images and files, the bot can share screenshots back
125
+ Want to build an example browser game? Respond in this thread."
126
+ `)
127
+ }
128
+ expect(created.map((channel) => channel.guildId)).toEqual([trustedGuildId])
129
+ expect((await client.guilds.cache.get(otherGuildId)!.channels.fetch()).size).toBe(1)
130
+ expect(await ensureDefaultChannelsWithWelcome({
131
+ guilds: [...client.guilds.cache.values()], discordClient: client,
132
+ appId: discord.botUserId, isGatewayMode: false,
133
+ })).toEqual([])
134
+ })
135
+ }
136
+
137
+ test('gateway startup provisions every proxy-authorized guild without local mappings', async () => {
138
+ const created = await ensureDefaultChannelsWithWelcome({
139
+ guilds: [...client.guilds.cache.values()], discordClient: client,
140
+ appId: discord.botUserId, isGatewayMode: true,
141
+ })
142
+ for (const channel of created) {
143
+ expect(await discord.channel(channel.id).text()).toMatchInlineSnapshot(`
144
+ "--- from: assistant (TestBot)
145
+ **Kimaki** lets you code from Discord. Send a message in any project channel and an AI agent edits code, runs commands, and searches your codebase — all on your machine.
146
+ **What you can do:**
147
+ - Use \`/add-project\` to create a Discord channel linked to one OpenCode project (git repo)
148
+ - Collaborate with teammates in the same session
149
+ - Upload images and files, the bot can share screenshots back
150
+ Want to build an example browser game? Respond in this thread."
151
+ `)
152
+ }
153
+ expect(created.map((channel) => channel.guildId).sort()).toEqual([trustedGuildId, otherGuildId])
154
+ expect(await findChannelsByDirectory({})).toHaveLength(2)
155
+ })
@@ -23,6 +23,7 @@ import {
23
23
  getChannelMentionMode,
24
24
  getChannelDirectory,
25
25
  cancelAllPendingIpcRequests,
26
+ consumeSessionSleepWake,
26
27
  deleteChannelDirectoryById,
27
28
  createPendingWorkspace,
28
29
  setWorkspaceReady,
@@ -33,6 +34,7 @@ import {
33
34
  import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js'
34
35
  import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js'
35
36
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js'
37
+ import { STATUS_PREFIX } from './message-formatting.js'
36
38
  import {
37
39
  escapeBackticksInCodeBlocks,
38
40
  splitMarkdownForDiscord,
@@ -88,6 +90,8 @@ import {
88
90
  getRuntime,
89
91
  getOrCreateRuntime,
90
92
  disposeRuntime,
93
+ reserveThreadIngress,
94
+ runInThreadIngressSlot,
91
95
  } from './session-handler/thread-session-runtime.js'
92
96
  import { runShellCommand } from './commands/run-command.js'
93
97
  import { registerInteractionHandler } from './interaction-handler.js'
@@ -112,7 +116,7 @@ export {
112
116
  closeDatabase,
113
117
  getChannelDirectory,
114
118
  } from './database.js'
115
- export { initializeOpencodeForDirectory } from './opencode.js'
119
+ export { initializeOpencodeForDirectory, assertCompatibleOpencodeVersion } from './opencode.js'
116
120
  export {
117
121
  escapeBackticksInCodeBlocks,
118
122
  splitMarkdownForDiscord,
@@ -144,6 +148,11 @@ import path from 'node:path'
144
148
  import dedent from 'string-dedent'
145
149
  import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js'
146
150
  import { writeHeapSnapshot, startHeapMonitor } from './heap-monitor.js'
151
+ import {
152
+ flushCpuProfiling,
153
+ startStdinCpuProfListener,
154
+ stopStdinCpuProfListener,
155
+ } from './cpu-profiler.js'
147
156
  import { startTaskRunner } from './task-runner.js'
148
157
  // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
149
158
  // Each session's event.subscribe() holds a connection; without enough connections,
@@ -435,8 +444,8 @@ export async function startDiscordBot({
435
444
  discordLogger.error(
436
445
  `[GATEWAY] Shard ${shardId} exceeded ${MAX_RECONNECT_ATTEMPTS} reconnect attempts, self-restarting`,
437
446
  )
438
- // Self-restart: cleanup, then exit non-zero so the bin.ts wrapper
439
- // restarts us. Without the wrapper this exits after logging a warning.
447
+ // Self-restart: cleanup, then SIGKILL so the bin.ts wrapper
448
+ // restarts us. Without the wrapper this dies after logging a warning.
440
449
  void selfRestart('gateway-reconnect-limit')
441
450
  }
442
451
  })
@@ -473,6 +482,11 @@ export async function startDiscordBot({
473
482
  })
474
483
 
475
484
  discordClient.on(Events.MessageCreate, async (message: Message) => {
485
+ const threadIngressSlot =
486
+ message.channel.isThread() || message.channel.type === ChannelType.GuildText
487
+ ? reserveThreadIngress(message.channel.id)
488
+ : undefined
489
+ await runInThreadIngressSlot(threadIngressSlot, async () => {
476
490
  try {
477
491
  const isSelfBotMessage = Boolean(
478
492
  discordClient.user && message.author?.id === discordClient.user.id,
@@ -727,6 +741,7 @@ export async function startDiscordBot({
727
741
  ) {
728
742
  const shellCmd = message.content.slice(1).trim()
729
743
  if (shellCmd) {
744
+ threadIngressSlot?.release()
730
745
  const shellDir =
731
746
  worktreeInfo?.status === 'ready' &&
732
747
  worktreeInfo.workspace_directory
@@ -752,6 +767,7 @@ export async function startDiscordBot({
752
767
  ? extractBtwSuffix(message.content || '')
753
768
  : null
754
769
  if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
770
+ threadIngressSlot?.release()
755
771
  const btwSdkDir =
756
772
  worktreeInfo?.status === 'ready' &&
757
773
  worktreeInfo.workspace_directory
@@ -821,7 +837,8 @@ export async function startDiscordBot({
821
837
  // Cancel interactive UI when a real user sends a message.
822
838
  // Context-only messages (user-to-user replies) should not interrupt
823
839
  // the active run or dismiss pending UI.
824
- if (!message.author.bot && !isCliInjectedPrompt && !isLeadingMentionToOtherUser) {
840
+ const dismissSourceUi = async () => {
841
+ if (message.author.bot || isCliInjectedPrompt || isLeadingMentionToOtherUser) return
825
842
  cancelPendingActionButtons(thread.id)
826
843
  cancelHtmlActionsForThread(thread.id)
827
844
  const dismissedPermission = await cancelPendingPermission(thread.id)
@@ -839,6 +856,27 @@ export async function startDiscordBot({
839
856
  }
840
857
  void cancelPendingFileUpload(thread.id)
841
858
  }
859
+ if (!hasVoiceAttachment) {
860
+ await dismissSourceUi()
861
+ }
862
+
863
+ // A sleep wake only becomes a turn if it can still claim its own row.
864
+ // The claim fails when the user cancelled the sleep while the wake was
865
+ // being posted, so the superseded wake is dropped instead of starting a
866
+ // turn the user already replaced. Cancellation for every other kind of
867
+ // message happens inside runtime.enqueueIncoming().
868
+ const isSleepWake = Boolean(promptMarker?.sleepWake)
869
+ if (isSleepWake) {
870
+ const claimedWake = promptMarker?.sleepId
871
+ ? await consumeSessionSleepWake({ deliveryId: promptMarker.sleepId })
872
+ : false
873
+ if (!claimedWake) {
874
+ discordLogger.log(
875
+ `[SLEEP] ignoring superseded wake in thread ${thread.id}`,
876
+ )
877
+ return
878
+ }
879
+ }
842
880
 
843
881
  // Expensive pre-processing (voice transcription, context fetch,
844
882
  // attachment download) runs inside the runtime's serialized
@@ -859,6 +897,7 @@ export async function startDiscordBot({
859
897
  permissions: cliInjectedPermissions,
860
898
  injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
861
899
  parentSessionId: cliInjectedParentSessionId,
900
+ isSleepWake: isSleepWake || undefined,
862
901
  noReply: isLeadingMentionToOtherUser || undefined,
863
902
  sessionStartSource: sessionStartSource
864
903
  ? {
@@ -867,8 +906,8 @@ export async function startDiscordBot({
867
906
  scheduledTaskRunId: sessionStartSource.scheduledTaskRunId,
868
907
  }
869
908
  : undefined,
870
- preprocess: () => {
871
- return preprocessExistingThreadMessage({
909
+ preprocess: async () => {
910
+ const result = await preprocessExistingThreadMessage({
872
911
  message,
873
912
  thread,
874
913
  projectDirectory: resolvedProjectDir,
@@ -877,6 +916,12 @@ export async function startDiscordBot({
877
916
  hasVoiceAttachment,
878
917
  appId: currentAppId,
879
918
  })
919
+ // Routing must finish before touching source UI. This chain is separate
920
+ // from dispatchAction, so abort waiting does not block session events.
921
+ if (hasVoiceAttachment && !result.skip) {
922
+ await dismissSourceUi()
923
+ }
924
+ return result
880
925
  },
881
926
  })
882
927
 
@@ -955,6 +1000,7 @@ export async function startDiscordBot({
955
1000
  if (message.content?.startsWith('!')) {
956
1001
  const shellCmd = message.content.slice(1).trim()
957
1002
  if (shellCmd) {
1003
+ threadIngressSlot?.release()
958
1004
  const loadingReply = await message.reply({
959
1005
  content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
960
1006
  })
@@ -1038,14 +1084,9 @@ export async function startDiscordBot({
1038
1084
  })
1039
1085
  }
1040
1086
 
1041
- const sessionDirectory = await (async () => {
1042
- if (!worktreePromise) {
1043
- return projectDirectory
1044
- }
1045
- const result = await worktreePromise
1046
- if (result instanceof Error) return projectDirectory
1047
- return result
1048
- })()
1087
+ const worktreeResult = worktreePromise ? await worktreePromise : projectDirectory
1088
+ if (worktreeResult instanceof Error) return
1089
+ const sessionDirectory = worktreeResult
1049
1090
 
1050
1091
  const channelRuntime = getOrCreateRuntime({
1051
1092
  threadId: thread.id,
@@ -1094,6 +1135,7 @@ export async function startDiscordBot({
1094
1135
  )
1095
1136
  }
1096
1137
  }
1138
+ })
1097
1139
  })
1098
1140
 
1099
1141
  // Handle user message edits to update queued messages.
@@ -1149,7 +1191,7 @@ export async function startDiscordBot({
1149
1191
  )
1150
1192
  await sendThreadMessage(
1151
1193
  channel,
1152
- `⬦ **${displayName}** removed message from queue`,
1194
+ `${STATUS_PREFIX}**${displayName}** removed message from queue`,
1153
1195
  )
1154
1196
  } else {
1155
1197
  discordLogger.log(
@@ -1157,7 +1199,7 @@ export async function startDiscordBot({
1157
1199
  )
1158
1200
  await sendThreadMessage(
1159
1201
  channel,
1160
- `⬦ **${displayName}** edited queued message`,
1202
+ `${STATUS_PREFIX}**${displayName}** edited queued message`,
1161
1203
  )
1162
1204
  }
1163
1205
  }
@@ -1188,7 +1230,7 @@ export async function startDiscordBot({
1188
1230
  )
1189
1231
  await sendThreadMessage(
1190
1232
  channel,
1191
- `⬦ **${removed.username}** removed message from queue`,
1233
+ `${STATUS_PREFIX}**${removed.username}** removed message from queue`,
1192
1234
  )
1193
1235
  } catch (error) {
1194
1236
  discordLogger.error(
@@ -1392,17 +1434,9 @@ export async function startDiscordBot({
1392
1434
 
1393
1435
  const botThreadStartSource = parseSessionStartSourceFromMarker(marker)
1394
1436
 
1395
- const sessionDirectory = await (async () => {
1396
- if (cwdDirectory) {
1397
- return cwdDirectory
1398
- }
1399
- if (!worktreePromise) {
1400
- return projectDirectory
1401
- }
1402
- const result = await worktreePromise
1403
- if (result instanceof Error) return projectDirectory
1404
- return result
1405
- })()
1437
+ const worktreeResult = worktreePromise ? await worktreePromise : undefined
1438
+ if (worktreeResult instanceof Error) return
1439
+ const sessionDirectory = cwdDirectory ?? worktreeResult ?? projectDirectory
1406
1440
 
1407
1441
  const runtime = getOrCreateRuntime({
1408
1442
  threadId: thread.id,
@@ -1505,6 +1539,7 @@ export async function startDiscordBot({
1505
1539
  }
1506
1540
 
1507
1541
  startHeapMonitor()
1542
+ startStdinCpuProfListener()
1508
1543
  const stopTaskRunner = startTaskRunner({ token })
1509
1544
  const stopRuntimeIdleSweeper = startRuntimeIdleSweeper()
1510
1545
 
@@ -1533,6 +1568,14 @@ export async function startDiscordBot({
1533
1568
  global.shuttingDown = true
1534
1569
 
1535
1570
  try {
1571
+ stopStdinCpuProfListener()
1572
+ const flushed = await flushCpuProfiling()
1573
+ if (flushed instanceof Error) {
1574
+ discordLogger.warn(
1575
+ 'Failed to flush CPU profile on shutdown:',
1576
+ flushed.message,
1577
+ )
1578
+ }
1536
1579
  await stopRuntimeIdleSweeper()
1537
1580
  await stopTaskRunner()
1538
1581
 
@@ -1626,13 +1669,11 @@ export async function startDiscordBot({
1626
1669
  })
1627
1670
  })
1628
1671
 
1629
- // Self-restart: exit with code 1 so the bin.ts wrapper restarts us with
1630
- // exponential backoff and crash-loop detection. When running without the
1631
- // wrapper (e.g. `tsx src/cli.ts`), the process just exits and the user
1632
- // sees the non-zero exit code — they should use `tsx src/bin.ts` instead
1633
- // for auto-restart support. The previous detached-spawn fallback was
1634
- // unreliable: process.exit(0) could kill the child before it started,
1635
- // there was no backoff between restarts, and spawn failures were silent.
1672
+ // Self-restart: die so the bin.ts wrapper restarts us with exponential
1673
+ // backoff and crash-loop detection. process.exit() can hang joining native
1674
+ // worker threads after Discord gateway failures, so SIGKILL instead.
1675
+ // When running without the wrapper (e.g. `tsx src/cli.ts`), the process
1676
+ // just dies — use `tsx src/bin.ts` for auto-restart support.
1636
1677
  let selfRestarting = false
1637
1678
  async function selfRestart(reason: string) {
1638
1679
  if (selfRestarting) {
@@ -1641,6 +1682,7 @@ export async function startDiscordBot({
1641
1682
  }
1642
1683
  selfRestarting = true
1643
1684
  discordLogger.log(`Self-restarting (reason: ${reason})...`)
1685
+ setTimeout(() => process.kill(process.pid, 'SIGKILL'), 15_000)
1644
1686
  try {
1645
1687
  await handleShutdown(reason, { skipExit: true })
1646
1688
  } catch (error) {
@@ -1652,7 +1694,7 @@ export async function startDiscordBot({
1652
1694
  'No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.',
1653
1695
  )
1654
1696
  }
1655
- process.exit(1)
1697
+ process.kill(process.pid, 'SIGKILL')
1656
1698
  }
1657
1699
 
1658
1700
  process.on('SIGUSR2', () => {
@@ -0,0 +1,20 @@
1
+ // Tests Discord slash command payloads for quick agent commands.
2
+
3
+ import { describe, expect, test } from 'vitest'
4
+ import { buildQuickAgentSlashCommand } from './discord-command-registration.js'
5
+
6
+ describe('buildQuickAgentSlashCommand', () => {
7
+ test('puts variant last after prompt', () => {
8
+ const command = buildQuickAgentSlashCommand({
9
+ commandName: 'plan-agent',
10
+ description: 'Switch to plan agent',
11
+ }).toJSON()
12
+
13
+ expect(command.options?.map((option) => option.name)).toMatchInlineSnapshot(`
14
+ [
15
+ "prompt",
16
+ "variant",
17
+ ]
18
+ `)
19
+ })
20
+ })
@@ -65,6 +65,32 @@ function truncateCommandDescription(description: string): string {
65
65
  return description.slice(0, 100)
66
66
  }
67
67
 
68
+ export function buildQuickAgentSlashCommand({
69
+ commandName,
70
+ description,
71
+ }: {
72
+ commandName: string
73
+ description: string
74
+ }) {
75
+ return new SlashCommandBuilder()
76
+ .setName(commandName)
77
+ .setDescription(truncateCommandDescription(description))
78
+ .setDMPermission(false)
79
+ .addStringOption((opt) =>
80
+ opt
81
+ .setName('prompt')
82
+ .setDescription('Send a prompt with this agent')
83
+ .setRequired(false),
84
+ )
85
+ .addStringOption((opt) =>
86
+ opt
87
+ .setName('variant')
88
+ .setDescription('Model thinking level for this agent')
89
+ .setRequired(false)
90
+ .setAutocomplete(true),
91
+ )
92
+ }
93
+
68
94
  export async function registerCommands({
69
95
  token,
70
96
  appId,
@@ -156,8 +182,22 @@ export async function registerCommands({
156
182
  new SlashCommandBuilder()
157
183
  .setName('merge-worktree')
158
184
  .setDescription(
159
- truncateCommandDescription('Squash-merge worktree into default branch. Aborts if main has uncommitted changes.'),
185
+ truncateCommandDescription('Merge worktree commits into the default branch'),
160
186
  )
187
+ .addStringOption((option) => {
188
+ option
189
+ .setName('strategy')
190
+ .setDescription(
191
+ truncateCommandDescription('How to merge commits (default: keep commits)'),
192
+ )
193
+ .setRequired(false)
194
+ .addChoices(
195
+ { name: 'Keep commits (rebase)', value: 'rebase' },
196
+ { name: 'Squash into one commit', value: 'squash' },
197
+ )
198
+
199
+ return option
200
+ })
161
201
  .addStringOption((option) => {
162
202
  option
163
203
  .setName('target-branch')
@@ -491,17 +531,7 @@ export async function registerCommands({
491
531
  })
492
532
 
493
533
  commands.push(
494
- new SlashCommandBuilder()
495
- .setName(commandName)
496
- .setDescription(truncateCommandDescription(description))
497
- .setDMPermission(false)
498
- .addStringOption((opt) =>
499
- opt
500
- .setName('prompt')
501
- .setDescription('Send a prompt with this agent')
502
- .setRequired(false),
503
- )
504
- .toJSON(),
534
+ buildQuickAgentSlashCommand({ commandName, description }).toJSON(),
505
535
  )
506
536
  }
507
537