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
@@ -3,9 +3,13 @@ import { afterEach, describe, expect, test } from 'vitest'
3
3
  import {
4
4
  hasKimakiAdminPermission,
5
5
  hasKimakiBotPermission,
6
+ raceDiscordRename,
7
+ resolveFooterMentionUserId,
6
8
  resolveGuildMessageMember,
9
+ resolveThreadFooterMentionUserId,
7
10
  splitMarkdownForDiscord,
8
11
  } from './discord-utils.js'
12
+ import type { ThreadChannel } from 'discord.js'
9
13
  import { store } from './store.js'
10
14
 
11
15
  describe('splitMarkdownForDiscord', () => {
@@ -292,3 +296,175 @@ describe('resolveGuildMessageMember', () => {
292
296
  await expect(resolveGuildMessageMember(message)).resolves.toBe(null)
293
297
  })
294
298
  })
299
+
300
+ describe('resolveFooterMentionUserId', () => {
301
+ test('uses the session user when it is not the bot', () => {
302
+ expect(resolveFooterMentionUserId({
303
+ sessionUserId: 'user-1',
304
+ botUserId: 'bot-1',
305
+ threadOwnerId: 'bot-1',
306
+ members: [
307
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
308
+ { id: 'user-2', bot: false, joinedTimestamp: 2 },
309
+ ],
310
+ })).toBe('user-1')
311
+ })
312
+
313
+ test('skips bots and uses the earliest human member when the bot created the thread', () => {
314
+ expect(resolveFooterMentionUserId({
315
+ sessionUserId: 'bot-1',
316
+ botUserId: 'bot-1',
317
+ threadOwnerId: 'bot-1',
318
+ members: [
319
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
320
+ { id: 'other-bot', bot: true, joinedTimestamp: 2 },
321
+ { id: 'user-3', bot: false, joinedTimestamp: 4 },
322
+ { id: 'user-2', bot: false, joinedTimestamp: 3 },
323
+ ],
324
+ })).toBe('user-2')
325
+ })
326
+
327
+ test('does not mention anyone when the bot created the thread and no human member exists', () => {
328
+ expect(resolveFooterMentionUserId({
329
+ sessionUserId: 'bot-1',
330
+ botUserId: 'bot-1',
331
+ threadOwnerId: 'bot-1',
332
+ members: [
333
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
334
+ { id: 'other-bot', bot: true, joinedTimestamp: 2 },
335
+ ],
336
+ })).toBeUndefined()
337
+ })
338
+
339
+ test('uses the first human member when the bot created the thread and no session user is set', () => {
340
+ expect(resolveFooterMentionUserId({
341
+ sessionUserId: undefined,
342
+ botUserId: 'bot-1',
343
+ threadOwnerId: 'bot-1',
344
+ members: [
345
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
346
+ { id: 'user-2', bot: false, joinedTimestamp: 2 },
347
+ ],
348
+ })).toBe('user-2')
349
+ })
350
+
351
+ test('does not mention a member whose bot flag is unknown', () => {
352
+ expect(resolveFooterMentionUserId({
353
+ sessionUserId: undefined,
354
+ botUserId: 'bot-1',
355
+ threadOwnerId: 'bot-1',
356
+ members: [
357
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
358
+ { id: 'maybe-user', joinedTimestamp: 2 },
359
+ ],
360
+ })).toBeUndefined()
361
+ })
362
+ })
363
+
364
+ function fakeThread({
365
+ ownerId,
366
+ botUserId,
367
+ members,
368
+ onFetch,
369
+ }: {
370
+ ownerId: string
371
+ botUserId: string
372
+ members: Array<{ id: string; bot?: boolean; joinedTimestamp?: number | null }>
373
+ onFetch?: () => Promise<Map<string, { id: string; user?: { bot?: boolean }; joinedTimestamp?: number | null }>>
374
+ }): ThreadChannel {
375
+ const cache = new Map(members.map((member) => [
376
+ member.id,
377
+ {
378
+ id: member.id,
379
+ user: member.bot === undefined ? undefined : { bot: member.bot },
380
+ joinedTimestamp: member.joinedTimestamp ?? null,
381
+ },
382
+ ]))
383
+ return {
384
+ ownerId,
385
+ client: { user: { id: botUserId } },
386
+ members: {
387
+ cache,
388
+ fetch: onFetch ?? (() => {
389
+ throw new Error('should not fetch thread members')
390
+ }),
391
+ },
392
+ } as ThreadChannel
393
+ }
394
+
395
+ describe('resolveThreadFooterMentionUserId', () => {
396
+ test('does not fetch when a human session user is already known', async () => {
397
+ await expect(resolveThreadFooterMentionUserId({
398
+ sessionUserId: 'user-1',
399
+ thread: fakeThread({
400
+ ownerId: 'bot-1',
401
+ botUserId: 'bot-1',
402
+ members: [{ id: 'bot-1', bot: true }],
403
+ }),
404
+ })).resolves.toBe('user-1')
405
+ })
406
+
407
+ test('does not fetch when the thread owner is not the bot', async () => {
408
+ await expect(resolveThreadFooterMentionUserId({
409
+ sessionUserId: undefined,
410
+ thread: fakeThread({
411
+ ownerId: 'user-1',
412
+ botUserId: 'bot-1',
413
+ members: [{ id: 'user-1', bot: false }],
414
+ }),
415
+ })).resolves.toBeUndefined()
416
+ })
417
+
418
+ test('uses cached humans without fetching', async () => {
419
+ await expect(resolveThreadFooterMentionUserId({
420
+ sessionUserId: undefined,
421
+ thread: fakeThread({
422
+ ownerId: 'bot-1',
423
+ botUserId: 'bot-1',
424
+ members: [
425
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
426
+ { id: 'user-2', bot: false, joinedTimestamp: 2 },
427
+ ],
428
+ }),
429
+ })).resolves.toBe('user-2')
430
+ })
431
+
432
+ test('fetches only when the bot owns the thread and no human is cached', async () => {
433
+ let fetched = false
434
+ const thread = fakeThread({
435
+ ownerId: 'bot-1',
436
+ botUserId: 'bot-1',
437
+ members: [{ id: 'bot-1', bot: true, joinedTimestamp: 1 }],
438
+ onFetch: async () => {
439
+ fetched = true
440
+ return new Map([
441
+ ['bot-1', { id: 'bot-1', user: { bot: true }, joinedTimestamp: 1 }],
442
+ ['user-2', { id: 'user-2', user: { bot: false }, joinedTimestamp: 2 }],
443
+ ])
444
+ },
445
+ })
446
+ await expect(resolveThreadFooterMentionUserId({
447
+ sessionUserId: undefined,
448
+ thread,
449
+ })).resolves.toBe('user-2')
450
+ expect(fetched).toBe(true)
451
+ })
452
+ })
453
+
454
+ describe('raceDiscordRename', () => {
455
+ test('returns timeout when rename hangs', async () => {
456
+ const result = await raceDiscordRename({
457
+ rename: new Promise(() => {}),
458
+ timeoutMs: 20,
459
+ })
460
+ expect(result).toBe('timeout')
461
+ })
462
+
463
+ test('returns rename result when it finishes first', async () => {
464
+ const result = await raceDiscordRename({
465
+ rename: Promise.resolve('ok'),
466
+ timeoutMs: 1000,
467
+ })
468
+ expect(result).toBe('ok')
469
+ })
470
+ })
@@ -21,6 +21,11 @@ import type { OpencodeClient } from '@opencode-ai/sdk/v2'
21
21
  import { discordApiUrl } from './discord-urls.js'
22
22
  import { Lexer } from 'marked'
23
23
  import { splitTablesFromMarkdown } from './format-tables.js'
24
+ import {
25
+ sessionPartContent,
26
+ shouldLeadWithBlankLine,
27
+ type SessionChunk,
28
+ } from './message-formatting.js'
24
29
  import { getChannelDirectory, getThreadWorktreeOrWorkspace } from './database.js'
25
30
  import { DiscordOperationError } from './errors.js'
26
31
  import { limitHeadingDepth } from './limit-heading-depth.js'
@@ -254,12 +259,6 @@ export async function archiveThread({
254
259
  if (updateResult instanceof Error) {
255
260
  discordLogger.warn(`[archive-thread] ${updateResult.message}`)
256
261
  }
257
-
258
- const abortResult = await client.session.abort({ sessionID: sessionId })
259
- .catch((e) => new Error('Failed to abort session', { cause: e }))
260
- if (abortResult instanceof Error) {
261
- discordLogger.warn(`[archive-thread] ${abortResult.message}`)
262
- }
263
262
  }
264
263
 
265
264
  if (archiveDelay > 0) {
@@ -275,6 +274,27 @@ export async function archiveThread({
275
274
  })
276
275
  }
277
276
 
277
+ export const DISCORD_THREAD_RENAME_TIMEOUT_MS = 3000
278
+
279
+ // Discord rename can hang on the 3rd call (~2 per 10 min). Race so it never blocks.
280
+ export async function raceDiscordRename<T>({
281
+ rename,
282
+ timeoutMs = DISCORD_THREAD_RENAME_TIMEOUT_MS,
283
+ }: {
284
+ rename: Promise<T>
285
+ timeoutMs?: number
286
+ }): Promise<T | 'timeout'> {
287
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
288
+ return Promise.race([
289
+ rename,
290
+ new Promise<'timeout'>((resolve) => {
291
+ timeoutSignal.addEventListener('abort', () => {
292
+ resolve('timeout')
293
+ })
294
+ }),
295
+ ])
296
+ }
297
+
278
298
  /**
279
299
  * Add a user as a thread member so the thread shows up in their Discord left
280
300
  * sidebar. Discord only lists threads you are a member of, so a scheduled
@@ -312,6 +332,91 @@ export async function ensureThreadMember({
312
332
  if (addMemberResult instanceof Error) return addMemberResult
313
333
  }
314
334
 
335
+ export type FooterMentionMember = {
336
+ id: string
337
+ bot?: boolean
338
+ joinedTimestamp?: number | null
339
+ }
340
+
341
+ export function resolveFooterMentionUserId({
342
+ sessionUserId,
343
+ botUserId,
344
+ threadOwnerId,
345
+ members,
346
+ }: {
347
+ sessionUserId: string | undefined
348
+ botUserId: string | undefined
349
+ threadOwnerId: string | undefined
350
+ members: FooterMentionMember[]
351
+ }): string | undefined {
352
+ if (sessionUserId && sessionUserId !== botUserId) return sessionUserId
353
+ if (!botUserId || threadOwnerId !== botUserId) return undefined
354
+ const humans = members
355
+ .filter((member) => member.id && member.id !== botUserId && member.bot === false)
356
+ .sort((a, b) => {
357
+ const aJoined = a.joinedTimestamp ?? Number.POSITIVE_INFINITY
358
+ const bJoined = b.joinedTimestamp ?? Number.POSITIVE_INFINITY
359
+ if (aJoined !== bJoined) return aJoined - bJoined
360
+ return a.id.localeCompare(b.id)
361
+ })
362
+ return humans[0]?.id
363
+ }
364
+
365
+ function footerMentionMembersFromCache(thread: ThreadChannel): FooterMentionMember[] {
366
+ return [...thread.members.cache.values()].map((member) => ({
367
+ id: member.id,
368
+ bot: member.user?.bot,
369
+ joinedTimestamp: member.joinedTimestamp ?? null,
370
+ }))
371
+ }
372
+
373
+ export async function resolveThreadFooterMentionUserId({
374
+ sessionUserId,
375
+ thread,
376
+ }: {
377
+ sessionUserId: string | undefined
378
+ thread: ThreadChannel
379
+ }): Promise<string | undefined> {
380
+ const botUserId = thread.client.user?.id
381
+ if (sessionUserId && sessionUserId !== botUserId) return sessionUserId
382
+ if (!botUserId || thread.ownerId !== botUserId) return undefined
383
+
384
+ const cached = footerMentionMembersFromCache(thread)
385
+ if (cached.some((member) => member.bot === false && member.id !== botUserId)) {
386
+ return resolveFooterMentionUserId({
387
+ sessionUserId,
388
+ botUserId,
389
+ threadOwnerId: thread.ownerId ?? undefined,
390
+ members: cached,
391
+ })
392
+ }
393
+
394
+ const fetched = await thread.members.fetch().catch((e) => {
395
+ return new DiscordOperationError({ operation: 'fetchThreadMembers', cause: e })
396
+ })
397
+ if (fetched instanceof Error) {
398
+ discordLogger.warn(`[FOOTER] Failed to fetch thread members: ${fetched.message}`)
399
+ return resolveFooterMentionUserId({
400
+ sessionUserId,
401
+ botUserId,
402
+ threadOwnerId: thread.ownerId ?? undefined,
403
+ members: cached,
404
+ })
405
+ }
406
+
407
+ const members = [...fetched.values()].map((member) => ({
408
+ id: member.id,
409
+ bot: member.user?.bot,
410
+ joinedTimestamp: member.joinedTimestamp ?? null,
411
+ }))
412
+ return resolveFooterMentionUserId({
413
+ sessionUserId,
414
+ botUserId,
415
+ threadOwnerId: thread.ownerId ?? undefined,
416
+ members,
417
+ })
418
+ }
419
+
315
420
  /** Remove Discord mentions from text so they don't appear in thread titles */
316
421
  export function stripMentions(text: string): string {
317
422
  return text
@@ -611,7 +716,7 @@ export function splitMarkdownForDiscord({
611
716
  }
612
717
 
613
718
  export async function sendThreadMessage(
614
- thread: ThreadChannel,
719
+ thread: ThreadChannel | TextChannel,
615
720
  content: string,
616
721
  options?: { flags?: number },
617
722
  ): Promise<Message> {
@@ -675,6 +780,46 @@ export async function sendThreadMessage(
675
780
  return firstMessage!
676
781
  }
677
782
 
783
+ export async function sendSessionPartMessage(
784
+ thread: ThreadChannel,
785
+ content: string,
786
+ options?: {
787
+ leadWithBlankLine?: boolean
788
+ flags?: number
789
+ },
790
+ ): Promise<Message> {
791
+ return sendThreadMessage(
792
+ thread,
793
+ sessionPartContent({
794
+ content,
795
+ leadWithBlankLine: options?.leadWithBlankLine === true,
796
+ }),
797
+ { flags: options?.flags },
798
+ )
799
+ }
800
+
801
+ export async function sendSessionPartBatches({
802
+ thread,
803
+ batches,
804
+ }: {
805
+ thread: ThreadChannel
806
+ batches: SessionChunk[]
807
+ }) {
808
+ const sent: Array<{ partIds: string[]; message: Message }> = []
809
+ let previousKind: SessionChunk['kind'] | undefined
810
+ for (const batch of batches) {
811
+ const message = await sendSessionPartMessage(thread, batch.content, {
812
+ leadWithBlankLine: shouldLeadWithBlankLine({
813
+ previousKind,
814
+ nextKind: batch.kind,
815
+ }),
816
+ })
817
+ previousKind = batch.kind
818
+ sent.push({ partIds: batch.partIds, message })
819
+ }
820
+ return sent
821
+ }
822
+
678
823
  export function isThreadChannelType(type: number): boolean {
679
824
  return [
680
825
  ChannelType.PublicThread,
package/src/errors.ts CHANGED
@@ -24,6 +24,12 @@ export class ServerNotReadyError extends errore.createTaggedError({
24
24
  'OpenCode client for directory "$directory" is not available because the shared server is not ready',
25
25
  }) {}
26
26
 
27
+ export class OpencodeIncompatibleVersionError extends errore.createTaggedError({
28
+ name: 'OpencodeIncompatibleVersionError',
29
+ message:
30
+ 'Kimaki is not compatible with OpenCode version $version. Install an OpenCode 1.x release.',
31
+ }) {}
32
+
27
33
  export class ApiKeyMissingError extends errore.createTaggedError({
28
34
  name: 'ApiKeyMissingError',
29
35
  message: '$service API key is required',
@@ -117,6 +123,11 @@ export class OpenCodeSdkError extends errore.createTaggedError({
117
123
  message: 'OpenCode SDK call failed: $operation',
118
124
  }) {}
119
125
 
126
+ export class InvalidModelError extends errore.createTaggedError({
127
+ name: 'InvalidModelError',
128
+ message: 'Invalid model "$model": $reason',
129
+ }) {}
130
+
120
131
  export class FilesystemOperationError extends errore.createTaggedError({
121
132
  name: 'FilesystemOperationError',
122
133
  message: 'Filesystem operation failed: $operation',
@@ -214,6 +225,7 @@ export type OpenCodeErrors =
214
225
  | DirectoryNotAccessibleError
215
226
  | ServerStartError
216
227
  | ServerNotReadyError
228
+ | OpencodeIncompatibleVersionError
217
229
 
218
230
  export type SessionErrors =
219
231
  | SessionNotFoundError
package/src/exec-async.ts CHANGED
@@ -1,32 +1,41 @@
1
- import { exec } from 'node:child_process'
1
+ import { exec, execFile } from 'node:child_process'
2
2
  import { promisify } from 'node:util'
3
3
 
4
4
  const DEFAULT_EXEC_TIMEOUT_MS = 10_000
5
5
 
6
6
  const _execAsync = promisify(exec)
7
+ const _execFileAsync = promisify(execFile)
7
8
 
8
9
  export function execAsync(
9
- command: string,
10
+ command: string | { command: string; args: string[] },
10
11
  options?: Parameters<typeof _execAsync>[1],
11
12
  ): Promise<{ stdout: string; stderr: string }> {
12
- const timeoutMs =
13
- (options as { timeout?: number })?.timeout || DEFAULT_EXEC_TIMEOUT_MS
14
- const execPromise = _execAsync(command, options) as Promise<{
15
- stdout: string
16
- stderr: string
17
- }> & { child?: import('node:child_process').ChildProcess }
13
+ const timeoutMs = options?.timeout || DEFAULT_EXEC_TIMEOUT_MS
14
+ const childPromise =
15
+ typeof command === 'string'
16
+ ? _execAsync(command, options)
17
+ : _execFileAsync(command.command, command.args, options)
18
+ const execPromise = childPromise.then(({ stdout, stderr }) => {
19
+ return {
20
+ stdout: typeof stdout === 'string' ? stdout : stdout.toString(),
21
+ stderr: typeof stderr === 'string' ? stderr : stderr.toString(),
22
+ }
23
+ })
24
+ const commandLabel = typeof command === 'string'
25
+ ? command
26
+ : [command.command, ...command.args].join(' ')
18
27
  let timer: ReturnType<typeof setTimeout> | undefined
19
28
  const timeoutPromise = new Promise<never>((_, reject) => {
20
29
  timer = setTimeout(() => {
21
- const pid = execPromise.child?.pid
30
+ const pid = childPromise.child?.pid
22
31
  if (pid) {
23
32
  try {
24
33
  process.kill(-pid, 'SIGTERM')
25
34
  } catch {
26
- execPromise.child?.kill('SIGTERM')
35
+ childPromise.child?.kill('SIGTERM')
27
36
  }
28
37
  }
29
- reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`))
38
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${commandLabel}`))
30
39
  }, timeoutMs)
31
40
  })
32
41
  return Promise.race([execPromise, timeoutPromise]).finally(() => {
@@ -81,18 +81,22 @@ describe('external directory permissions', () => {
81
81
  "--- from: user (external-directory-tester)
82
82
  EXTERNAL_DIRECTORY_PROBE_MARKER first
83
83
  --- from: assistant (TestBot)
84
- *using deterministic-provider/deterministic-v2*
85
- ⬥ reading external directory
84
+ > *using deterministic-provider/deterministic-v2*
85
+ > reading external directory
86
+
86
87
  ┣ read *probe.txt*
87
- ⬥ external-directory-probe-done
88
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
88
+
89
+ > external-directory-probe-done
90
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
89
91
  --- from: user (external-directory-tester)
90
92
  EXTERNAL_DIRECTORY_PROBE_MARKER followup
91
93
  --- from: assistant (TestBot)
92
- ⬥ reading external directory
94
+ > reading external directory
95
+
93
96
  ┣ read *probe.txt*
94
- ⬥ external-directory-probe-done
95
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
97
+
98
+ > external-directory-probe-done
99
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
96
100
  `)
97
101
  expect(text).not.toContain('Permission Required')
98
102
  })
@@ -67,10 +67,12 @@ describe('external directory project deny', () => {
67
67
  "--- from: user (external-directory-deny-tester)
68
68
  EXTERNAL_DIRECTORY_PROBE_MARKER denied
69
69
  --- from: assistant (TestBot)
70
- *using deterministic-provider/deterministic-v2*
71
- ⬥ reading external directory
70
+ > *using deterministic-provider/deterministic-v2*
71
+ > reading external directory
72
+
72
73
  ┣ read *probe.txt*
73
- ⬥ external-directory-probe-denied"
74
+
75
+ > external-directory-probe-denied"
74
76
  `)
75
77
 
76
78
  // A deny is silent: it must not fall back to asking the user.