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
@@ -1,7 +1,4 @@
1
- // E2e test: queued message must drain after the user answers a pending question
2
- // via the Discord dropdown select menu. Reproduces a bug where answering via
3
- // select (not text) leaves queued messages stuck because the session continues
4
- // processing after the answer and may enter another blocking state.
1
+ // E2e test for ordered queue drain after a pending question is answered via select.
5
2
 
6
3
  import { describe, test, expect } from 'vitest'
7
4
  import {
@@ -53,10 +50,7 @@ async function expectNoBotMessageContaining({
53
50
  while (Date.now() - start < timeout) {
54
51
  const messages = await discord.thread(threadId).getMessages()
55
52
  const match = messages.find((message) => {
56
- return (
57
- message.author.id === discord.botUserId
58
- && message.content.includes(text)
59
- )
53
+ return message.author.id === discord.botUserId && message.content.includes(text)
60
54
  })
61
55
  if (match) {
62
56
  throw new Error(
@@ -78,132 +72,19 @@ describe('queue drain after question select answer', () => {
78
72
  })
79
73
 
80
74
  test(
81
- 'queued message drains after answering question via dropdown select',
82
- async () => {
83
- // 1. Send a message that triggers the question tool
84
- await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
85
- content: 'QUESTION_SELECT_QUEUE_MARKER',
86
- })
87
-
88
- const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
89
- timeout: 8_000,
90
- predicate: (t) => {
91
- return t.name === 'QUESTION_SELECT_QUEUE_MARKER'
92
- },
93
- })
94
-
95
- const th = ctx.discord.thread(thread.id)
96
-
97
- // 2. Wait for the question dropdown message to appear in Discord.
98
- // Uses visible message wait instead of internal Map polling which
99
- // is too timing-sensitive on CI.
100
- const questionMessages = await waitForBotMessageContaining({
101
- discord: ctx.discord,
102
- threadId: thread.id,
103
- text: 'How to proceed?',
104
- timeout: 12_000,
105
- })
106
-
107
- // Get the pending question context hash from the internal map.
108
- // By this point the question message is visible so the context must exist.
109
- const pending = await waitForPendingQuestion({
110
- threadId: thread.id,
111
- timeoutMs: 8_000,
112
- })
113
- const questionMsg = questionMessages.find((m) => {
114
- return m.content.includes('How to proceed?')
115
- })!
116
- expect(questionMsg).toBeTruthy()
117
-
118
- // 3. Queue a message while question is pending
119
- const { id: queueInteractionId } = await th.user(TEST_USER_ID)
120
- .runSlashCommand({
121
- name: 'queue',
122
- options: [{ name: 'message', type: 3, value: 'Reply with exactly: post-question-drain' }],
123
- })
124
-
125
- const queueAck = await th.waitForInteractionAck({
126
- interactionId: queueInteractionId,
127
- timeout: 8_000,
128
- })
129
- if (!queueAck.messageId) {
130
- throw new Error('Expected /queue response message id')
131
- }
132
-
133
- // 4. The first queued item should be handed off immediately even while
134
- // the question is still pending, so the visible dispatch indicator
135
- // appears before the user answers the dropdown.
136
- await waitForBotMessageContaining({
137
- discord: ctx.discord,
138
- threadId: thread.id,
139
- text: '» **question-select-tester:** Reply with exactly: post-question-drain',
140
- timeout: 8_000,
141
- })
142
-
143
- // 5. Answer the question via dropdown select (pick first option "Alpha")
144
- const interaction = await th.user(TEST_USER_ID).selectMenu({
145
- messageId: questionMsg.id,
146
- customId: `ask_question:${pending.contextHash}:0`,
147
- values: ['0'],
148
- })
149
-
150
- await th.waitForInteractionAck({
151
- interactionId: interaction.id,
152
- timeout: 8_000,
153
- })
154
-
155
- // 6. Wait for footer from the drained queued message
156
- await waitForFooterMessage({
157
- discord: ctx.discord,
158
- threadId: thread.id,
159
- timeout: 8_000,
160
- afterMessageIncludes: '» **question-select-tester:**',
161
- afterAuthorId: ctx.discord.botUserId,
162
- })
163
-
164
- const timeline = await th.text({ showInteractions: true })
165
- expect(timeline).toMatchInlineSnapshot(`
166
- "--- from: user (question-select-tester)
167
- QUESTION_SELECT_QUEUE_MARKER
168
- --- from: assistant (TestBot)
169
- *using deterministic-provider/deterministic-v2*
170
- **Select action**
171
- How to proceed?
172
- ✓ _Alpha_
173
- [user interaction]
174
- » **question-select-tester:** Reply with exactly: post-question-drain
175
- Queued message (position 1)
176
- [user selects dropdown: 0]
177
- » **question-select-tester:** Alpha
178
- ⬥ ok
179
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
180
- `)
181
- expect(timeline).toContain('QUESTION_SELECT_QUEUE_MARKER')
182
- expect(timeline).toContain('How to proceed?')
183
- expect(timeline).toContain('[user selects dropdown: 0]')
184
- expect(timeline).toContain('» **question-select-tester:** Reply with exactly: post-question-drain')
185
- expect(timeline).toContain('⬥ ok')
186
- expect(timeline).toContain('*project ⋅ main ⋅')
187
- },
188
- 20_000,
189
- )
190
-
191
- test(
192
- 'only the first queued message is handed off after dropdown answer',
75
+ 'queued messages drain in order after answering via dropdown select',
193
76
  async () => {
194
- const marker = 'QUESTION_SELECT_QUEUE_MARKER second-test'
195
-
77
+ const marker = 'QUESTION_SELECT_QUEUE_MARKER'
196
78
  await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
197
79
  content: marker,
198
80
  })
199
81
 
200
82
  const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
201
83
  timeout: 8_000,
202
- predicate: (t) => {
203
- return t.name === marker
84
+ predicate: (candidate) => {
85
+ return candidate.name === marker
204
86
  },
205
87
  })
206
-
207
88
  const th = ctx.discord.thread(thread.id)
208
89
 
209
90
  const questionMessages = await waitForBotMessageContaining({
@@ -212,21 +93,18 @@ describe('queue drain after question select answer', () => {
212
93
  text: 'How to proceed?',
213
94
  timeout: 12_000,
214
95
  })
215
-
216
96
  const pending = await waitForPendingQuestion({
217
97
  threadId: thread.id,
218
98
  timeoutMs: 8_000,
219
99
  })
220
-
221
- const questionMsg = questionMessages.find((message) => {
100
+ const questionMessage = questionMessages.find((message) => {
222
101
  return message.content.includes('How to proceed?')
223
102
  })
224
- expect(questionMsg).toBeTruthy()
225
- if (!questionMsg) {
103
+ if (!questionMessage) {
226
104
  throw new Error('Expected question message')
227
105
  }
228
106
 
229
- const firstQueuedPrompt = 'SLOW_ABORT_MARKER run long response'
107
+ const firstQueuedPrompt = 'QUESTION_SELECT_DRAIN_FIRST_MARKER'
230
108
  const secondQueuedPrompt = 'Reply with exactly: post-question-second'
231
109
 
232
110
  const { id: firstQueueInteractionId } = await th.user(TEST_USER_ID)
@@ -234,48 +112,45 @@ describe('queue drain after question select answer', () => {
234
112
  name: 'queue',
235
113
  options: [{ name: 'message', type: 3, value: firstQueuedPrompt }],
236
114
  })
237
-
238
115
  await th.waitForInteractionAck({
239
116
  interactionId: firstQueueInteractionId,
240
117
  timeout: 8_000,
241
118
  })
242
119
 
120
+ await waitForBotMessageContaining({
121
+ discord: ctx.discord,
122
+ threadId: thread.id,
123
+ text: `» **question-select-tester:** ${firstQueuedPrompt}`,
124
+ timeout: 8_000,
125
+ })
126
+
243
127
  const { id: secondQueueInteractionId } = await th.user(TEST_USER_ID)
244
128
  .runSlashCommand({
245
129
  name: 'queue',
246
130
  options: [{ name: 'message', type: 3, value: secondQueuedPrompt }],
247
131
  })
248
-
249
132
  await th.waitForInteractionAck({
250
133
  interactionId: secondQueueInteractionId,
251
134
  timeout: 8_000,
252
135
  })
253
136
 
137
+ await expectNoBotMessageContaining({
138
+ discord: ctx.discord,
139
+ threadId: thread.id,
140
+ text: `» **question-select-tester:** ${secondQueuedPrompt}`,
141
+ timeout: 200,
142
+ })
143
+
254
144
  const interaction = await th.user(TEST_USER_ID).selectMenu({
255
- messageId: questionMsg.id,
145
+ messageId: questionMessage.id,
256
146
  customId: `ask_question:${pending.contextHash}:0`,
257
147
  values: ['0'],
258
148
  })
259
-
260
149
  await th.waitForInteractionAck({
261
150
  interactionId: interaction.id,
262
151
  timeout: 8_000,
263
152
  })
264
153
 
265
- await waitForBotMessageContaining({
266
- discord: ctx.discord,
267
- threadId: thread.id,
268
- text: `» **question-select-tester:** ${firstQueuedPrompt}`,
269
- timeout: 8_000,
270
- })
271
-
272
- await expectNoBotMessageContaining({
273
- discord: ctx.discord,
274
- threadId: thread.id,
275
- text: `» **question-select-tester:** ${secondQueuedPrompt}`,
276
- timeout: 200,
277
- })
278
-
279
154
  await waitForFooterMessage({
280
155
  discord: ctx.discord,
281
156
  threadId: thread.id,
@@ -283,14 +158,12 @@ describe('queue drain after question select answer', () => {
283
158
  afterMessageIncludes: `» **question-select-tester:** ${firstQueuedPrompt}`,
284
159
  afterAuthorId: ctx.discord.botUserId,
285
160
  })
286
-
287
161
  await waitForBotMessageContaining({
288
162
  discord: ctx.discord,
289
163
  threadId: thread.id,
290
164
  text: `» **question-select-tester:** ${secondQueuedPrompt}`,
291
165
  timeout: 8_000,
292
166
  })
293
-
294
167
  await waitForFooterMessage({
295
168
  discord: ctx.discord,
296
169
  threadId: thread.id,
@@ -302,30 +175,32 @@ describe('queue drain after question select answer', () => {
302
175
  const timeline = await th.text({ showInteractions: true })
303
176
  expect(timeline).toMatchInlineSnapshot(`
304
177
  "--- from: user (question-select-tester)
305
- QUESTION_SELECT_QUEUE_MARKER second-test
178
+ QUESTION_SELECT_QUEUE_MARKER
306
179
  --- from: assistant (TestBot)
307
- *using deterministic-provider/deterministic-v2*
180
+ > *using deterministic-provider/deterministic-v2*
308
181
  **Select action**
309
182
  How to proceed?
310
183
  ✓ _Alpha_
311
184
  [user interaction]
312
- » **question-select-tester:** SLOW_ABORT_MARKER run long response
185
+ » **question-select-tester:** QUESTION_SELECT_DRAIN_FIRST_MARKER
313
186
  Queued message (position 1)
314
187
  [user interaction]
315
188
  Queued message (position 1)
316
189
  [user selects dropdown: 0]
317
190
  » **question-select-tester:** Alpha
318
- ⬥ slow-response-started
319
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
191
+ > question-drain-first
192
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2*
320
193
  » **question-select-tester:** Reply with exactly: post-question-second
321
- ⬥ ok
322
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
194
+ > ok
195
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
323
196
  `)
197
+ expect(timeline).toContain('How to proceed?')
198
+ expect(timeline).toContain('[user selects dropdown: 0]')
324
199
  expect(timeline).toContain(`» **question-select-tester:** ${firstQueuedPrompt}`)
325
- expect(timeline).toContain('⬥ slow-response-started')
200
+ expect(timeline).toContain('question-drain-first')
326
201
  expect(timeline).toContain(`» **question-select-tester:** ${secondQueuedPrompt}`)
327
- expect(timeline).toContain('⬥ ok')
202
+ expect(timeline).toContain('ok')
328
203
  },
329
- 20_000,
204
+ 15_000,
330
205
  )
331
206
  })
@@ -39,6 +39,7 @@ import {
39
39
  chooseLockPort,
40
40
  cleanupTestSessions,
41
41
  initTestGitRepo,
42
+ isFooterMessage,
42
43
  waitForBotMessageContaining,
43
44
  waitForBotReplyAfterUserMessage,
44
45
  } from './test-utils.js'
@@ -264,11 +265,11 @@ describe('runtime lifecycle', () => {
264
265
  }, 5_000)
265
266
 
266
267
  test(
267
- 'three sequential completions reuse same runtime and listener',
268
+ 'two sequential completions reuse same runtime and listener',
268
269
  async () => {
269
- // Sends A, waits for full completion (footer), sends B, waits for
270
- // footer, sends C, waits for footer. Proves the listener stays alive
271
- // across full run cycles without any interrupt/queue involvement.
270
+ // Sends A, waits for full completion (footer), then sends B and waits
271
+ // for its footer. Proves the listener stays alive across full run cycles
272
+ // without any interrupt/queue involvement.
272
273
  // This is the "calm" path — no abort, no queue, just sequential use.
273
274
 
274
275
  // 1. Send first message → thread created, session established
@@ -323,51 +324,20 @@ describe('runtime lifecycle', () => {
323
324
 
324
325
  // Same runtime instance — listener was not recreated
325
326
  const runtimeAfterB = getRuntime(thread.id)
326
- expect(runtimeAfterB).toBe(runtimeAfterA)
327
-
328
- // 3. Send C after B fully completed
329
- await th.user(TEST_USER_ID).sendMessage({
330
- content: 'Reply with exactly: seq-gamma',
331
- })
332
-
333
- await waitForBotReplyAfterUserMessage({
334
- discord,
335
- threadId: thread.id,
336
- userId: TEST_USER_ID,
337
- userMessageIncludes: 'seq-gamma',
338
- timeout: 4_000,
339
- })
340
-
341
- await waitForBotMessageContaining({
342
- discord,
343
- threadId: thread.id,
344
- userId: TEST_USER_ID,
345
- text: '*project',
346
- afterUserMessageIncludes: 'seq-gamma',
347
- timeout: 4_000,
348
- })
349
-
350
- // Still the same runtime — three full cycles, one runtime, one listener
351
- const runtimeAfterC = getRuntime(thread.id)
352
327
  expect(await th.text()).toMatchInlineSnapshot(`
353
328
  "--- from: user (lifecycle-tester)
354
329
  Reply with exactly: seq-alpha
355
330
  --- from: assistant (TestBot)
356
- *using deterministic-provider/deterministic-v2*
357
- ⬥ ok
358
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
331
+ > *using deterministic-provider/deterministic-v2*
332
+ > ok
333
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000888>
359
334
  --- from: user (lifecycle-tester)
360
335
  Reply with exactly: seq-beta
361
336
  --- from: assistant (TestBot)
362
- ⬥ ok
363
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
364
- --- from: user (lifecycle-tester)
365
- Reply with exactly: seq-gamma
366
- --- from: assistant (TestBot)
367
- ⬥ ok
368
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
337
+ > ok
338
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000888>"
369
339
  `)
370
- expect(runtimeAfterC).toBe(runtimeAfterA)
340
+ expect(runtimeAfterB).toBe(runtimeAfterA)
371
341
  },
372
342
  15_000,
373
343
  )
@@ -398,10 +368,7 @@ describe('runtime lifecycle', () => {
398
368
  const messages = await discord.thread(thread.id).getMessages()
399
369
 
400
370
  const footerMessage = messages.find((message) => {
401
- if (message.author.id !== discord.botUserId) {
402
- return false
403
- }
404
- if (!message.content.startsWith('*')) {
371
+ if (!isFooterMessage({ message, botUserId: discord.botUserId })) {
405
372
  return false
406
373
  }
407
374
  return message.content.includes('deterministic-v2') && message.content.includes('%')
@@ -411,9 +378,9 @@ describe('runtime lifecycle', () => {
411
378
  "--- from: user (lifecycle-tester)
412
379
  Reply with exactly: footer-check
413
380
  --- from: assistant (TestBot)
414
- *using deterministic-provider/deterministic-v2*
415
- ⬥ ok
416
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
381
+ > *using deterministic-provider/deterministic-v2*
382
+ > ok
383
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000888>"
417
384
  `)
418
385
  expect(footerMessage).toBeDefined()
419
386
  if (!footerMessage) {
@@ -483,14 +450,14 @@ describe('runtime lifecycle', () => {
483
450
  "--- from: user (lifecycle-tester)
484
451
  Reply with exactly: reconnect-alpha
485
452
  --- from: assistant (TestBot)
486
- *using deterministic-provider/deterministic-v2*
487
- ⬥ ok
488
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
453
+ > *using deterministic-provider/deterministic-v2*
454
+ > ok
455
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000888>
489
456
  --- from: user (lifecycle-tester)
490
457
  Reply with exactly: reconnect-beta
491
458
  --- from: assistant (TestBot)
492
- ⬥ ok
493
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
459
+ > ok
460
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000888>"
494
461
  `)
495
462
 
496
463
  const runtimeAfterRestart = getRuntime(thread.id)
@@ -531,7 +498,7 @@ describe('runtime lifecycle', () => {
531
498
  "--- from: user (lifecycle-tester)
532
499
  Reply with exactly: footer-high-usage
533
500
  --- from: assistant (TestBot)
534
- *using deterministic-provider/deterministic-v2*"
501
+ > *using deterministic-provider/deterministic-v2*"
535
502
  `)
536
503
 
537
504
  const threadText = await discord.thread(thread.id).text()
package/src/schema.sql CHANGED
@@ -105,7 +105,7 @@ CREATE TABLE IF NOT EXISTS `part_messages` (
105
105
  `message_id` text NOT NULL,
106
106
  `thread_id` text NOT NULL,
107
107
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
108
- CONSTRAINT `fk_part_messages_thread_id_thread_sessions_thread_id_fk` FOREIGN KEY (`thread_id`) REFERENCES `thread_sessions`(`thread_id`) ON UPDATE CASCADE
108
+ CONSTRAINT `fk_part_messages_thread_id_thread_sessions_thread_id_fk` FOREIGN KEY (`thread_id`) REFERENCES `thread_sessions`(`thread_id`) ON UPDATE CASCADE ON DELETE CASCADE
109
109
  );
110
110
 
111
111
  CREATE TABLE IF NOT EXISTS `scheduled_task_runs` (
@@ -168,6 +168,17 @@ CREATE TABLE IF NOT EXISTS `session_models` (
168
168
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP
169
169
  );
170
170
 
171
+ CREATE TABLE IF NOT EXISTS `session_sleeps` (
172
+ `session_id` text PRIMARY KEY,
173
+ `wake_at` datetime NOT NULL,
174
+ `reason` text,
175
+ `status` text DEFAULT 'planned' NOT NULL,
176
+ `delivery_id` text NOT NULL,
177
+ `attempts` integer DEFAULT 0 NOT NULL,
178
+ `last_attempt_at` datetime,
179
+ `created_at` datetime DEFAULT CURRENT_TIMESTAMP
180
+ );
181
+
171
182
  CREATE TABLE IF NOT EXISTS `session_start_sources` (
172
183
  `session_id` text PRIMARY KEY,
173
184
  `schedule_kind` text NOT NULL,
@@ -183,7 +194,8 @@ CREATE TABLE IF NOT EXISTS `thread_sessions` (
183
194
  `source` text DEFAULT 'kimaki' NOT NULL,
184
195
  `last_synced_name` text,
185
196
  `parent_session_id` text,
186
- `created_at` datetime DEFAULT CURRENT_TIMESTAMP
197
+ `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
198
+ `updated_at` datetime DEFAULT CURRENT_TIMESTAMP
187
199
  );
188
200
 
189
201
  CREATE TABLE IF NOT EXISTS `thread_workspaces` (
@@ -219,4 +231,5 @@ CREATE INDEX IF NOT EXISTS `scheduled_tasks_channel_id_status_idx` ON `scheduled
219
231
  CREATE INDEX IF NOT EXISTS `scheduled_tasks_thread_id_status_idx` ON `scheduled_tasks` (`thread_id`,`status`);
220
232
  CREATE INDEX IF NOT EXISTS `session_events_session_id_timestamp_event_index_id_idx` ON `session_events` (`session_id`,`timestamp`,`event_index`,`id`);
221
233
  CREATE INDEX IF NOT EXISTS `session_events_thread_id_timestamp_event_index_id_idx` ON `session_events` (`thread_id`,`timestamp`,`event_index`,`id`);
234
+ CREATE INDEX IF NOT EXISTS `session_sleeps_status_wake_at_idx` ON `session_sleeps` (`status`,`wake_at`);
222
235
  CREATE INDEX IF NOT EXISTS `session_start_sources_scheduled_task_id_idx` ON `session_start_sources` (`scheduled_task_id`);
package/src/schema.ts CHANGED
@@ -33,6 +33,10 @@ export const thread_sessions = sqliteCore.sqliteTable('thread_sessions', {
33
33
  // Survives bot restarts so child multi-turn system prompts keep the parent ID.
34
34
  parent_session_id: sqliteCore.text('parent_session_id'),
35
35
  created_at: datetime('created_at').default(orm.sql`CURRENT_TIMESTAMP`),
36
+ // Bumped on every (re)binding of a session to this thread. /resume can map one
37
+ // session to several threads, so session_id -> thread_id needs a tiebreaker.
38
+ // Without it the reverse lookup is arbitrary and tools can target a dead thread.
39
+ updated_at: datetime('updated_at').default(orm.sql`CURRENT_TIMESTAMP`).$onUpdate(() => new Date()),
36
40
  })
37
41
 
38
42
  export const session_events = sqliteCore.sqliteTable('session_events', {
@@ -50,7 +54,7 @@ export const session_events = sqliteCore.sqliteTable('session_events', {
50
54
  export const part_messages = sqliteCore.sqliteTable('part_messages', {
51
55
  part_id: sqliteCore.text('part_id').primaryKey().notNull(),
52
56
  message_id: sqliteCore.text('message_id').notNull(),
53
- thread_id: sqliteCore.text('thread_id').notNull().references(() => thread_sessions.thread_id, { onUpdate: 'cascade' }),
57
+ thread_id: sqliteCore.text('thread_id').notNull().references(() => thread_sessions.thread_id, { onDelete: 'cascade', onUpdate: 'cascade' }),
54
58
  created_at: datetime('created_at').default(orm.sql`CURRENT_TIMESTAMP`),
55
59
  })
56
60
 
@@ -224,6 +228,36 @@ export const forum_sync_configs = sqliteCore.sqliteTable('forum_sync_configs', {
224
228
  sqliteCore.uniqueIndex('forum_sync_configs_app_id_forum_channel_id_key').on(table.app_id, table.forum_channel_id),
225
229
  ])
226
230
 
231
+ // Durable "wake this session later" rows for the kimaki_sleep tool.
232
+ //
233
+ // Delivery is at-least-once, and ingress makes it at-most-once-per-turn:
234
+ //
235
+ // planned ──► consumed cancelled / failed are terminal
236
+ // ▲ │
237
+ // └───┘ retried until ingress consumes it
238
+ //
239
+ // A row stays `planned` until ingress turns the wake into a turn, so ANY loss
240
+ // (crash before posting, crash after posting, missed gateway event) is covered
241
+ // by the same retry. `delivery_id` is the Discord message nonce, so a retry
242
+ // returns the existing message instead of waking the session twice.
243
+ //
244
+ // There is no thread_id: the owning thread is resolved from session_id at wake
245
+ // time, so a session rebound by /resume wakes in its current thread.
246
+ export const session_sleeps = sqliteCore.sqliteTable('session_sleeps', {
247
+ session_id: sqliteCore.text('session_id').primaryKey().notNull(),
248
+ wake_at: datetime('wake_at').notNull(),
249
+ reason: sqliteCore.text('reason'),
250
+ status: sqliteCore.text('status', { enum: ['planned', 'consumed', 'cancelled', 'failed'] }).notNull().default('planned'),
251
+ // Regenerated per sleep occurrence. Doubles as the Discord nonce and as a
252
+ // generation guard so a stale in-flight wake cannot mutate a newer sleep.
253
+ delivery_id: sqliteCore.text('delivery_id').notNull().$defaultFn(() => crypto.randomUUID()),
254
+ attempts: sqliteCore.integer('attempts', { mode: 'number' }).notNull().default(0),
255
+ last_attempt_at: datetime('last_attempt_at'),
256
+ created_at: datetime('created_at').default(orm.sql`CURRENT_TIMESTAMP`),
257
+ }, (table) => [
258
+ sqliteCore.index('session_sleeps_status_wake_at_idx').on(table.status, table.wake_at),
259
+ ])
260
+
227
261
  export const ipc_requests = sqliteCore.sqliteTable('ipc_requests', {
228
262
  id: sqliteCore.text('id').primaryKey().notNull().$defaultFn(() => crypto.randomUUID()),
229
263
  type: sqliteCore.text('type', { enum: ['file_upload', 'action_buttons'] }).notNull(),
@@ -259,6 +293,7 @@ export const relations = defineRelations({
259
293
  scheduled_task_runs,
260
294
  session_start_sources,
261
295
  forum_sync_configs,
296
+ session_sleeps,
262
297
  ipc_requests,
263
298
  }, (r) => ({
264
299
  thread_sessions: {
@@ -340,6 +375,7 @@ export const relations = defineRelations({
340
375
  export type BotMode = typeof bot_tokens.$inferSelect.bot_mode
341
376
  export type ChannelType = typeof channel_directories.$inferSelect.channel_type
342
377
  export type IpcRequestType = typeof ipc_requests.$inferSelect.type
378
+ export type SessionSleepStatus = typeof session_sleeps.$inferSelect.status
343
379
  export type SessionEvent = typeof session_events.$inferSelect
344
380
  export type ThreadSessionSource = typeof thread_sessions.$inferSelect.source
345
381
  export type VerbosityLevel = typeof channel_verbosity.$inferSelect.verbosity