kimaki 0.26.0 → 0.27.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 (214) hide show
  1. package/dist/agent-model.e2e.test.js +25 -122
  2. package/dist/anthropic-account-identity.js +1 -0
  3. package/dist/anthropic-auth-plugin.js +1 -0
  4. package/dist/anthropic-auth-state.js +1 -0
  5. package/dist/bin.js +73 -19
  6. package/dist/cli-commands/maintenance.js +46 -7
  7. package/dist/cli-commands/send.js +24 -0
  8. package/dist/cli-commands/session.js +183 -8
  9. package/dist/cli-commands/task.js +13 -1
  10. package/dist/cli-parsing.test.js +59 -3
  11. package/dist/cli-runner.js +28 -4
  12. package/dist/cli-runner.test.js +27 -0
  13. package/dist/cli-send-thread.e2e.test.js +1 -1
  14. package/dist/cli.js +32 -0
  15. package/dist/commands/abort.js +5 -1
  16. package/dist/commands/agent.js +6 -8
  17. package/dist/commands/ask-question.js +21 -32
  18. package/dist/commands/ask-question.test.js +43 -1
  19. package/dist/commands/login.js +6 -0
  20. package/dist/commands/merge-worktree.js +36 -10
  21. package/dist/commands/model-variant.js +7 -1
  22. package/dist/commands/model.js +14 -8
  23. package/dist/commands/multioauth.js +1 -0
  24. package/dist/commands/new-worktree.js +170 -102
  25. package/dist/commands/user-command.js +6 -8
  26. package/dist/commands/worktrees.js +5 -13
  27. package/dist/config.js +16 -1
  28. package/dist/database.js +124 -3
  29. package/dist/db.js +22 -0
  30. package/dist/db.test.js +167 -1
  31. package/dist/discord-bot.js +34 -32
  32. package/dist/discord-command-registration.js +9 -1
  33. package/dist/discord-utils.js +13 -5
  34. package/dist/discord-utils.test.js +17 -1
  35. package/dist/errors.js +5 -0
  36. package/dist/exec-async.js +17 -5
  37. package/dist/external-directory-permissions.e2e.test.js +2 -2
  38. package/dist/file-edit-log.js +197 -0
  39. package/dist/file-edit-log.test.js +258 -0
  40. package/dist/format-tables.js +52 -0
  41. package/dist/format-tables.test.js +93 -0
  42. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  43. package/dist/gateway-proxy.e2e.test.js +5 -5
  44. package/dist/git-worktree-core.js +182 -17
  45. package/dist/ipc-tools-plugin.js +63 -3
  46. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  47. package/dist/kimaki-opencode-plugin.js +7 -3
  48. package/dist/kimaki-workspace-adaptor.js +33 -10
  49. package/dist/logger.js +10 -1
  50. package/dist/markdown.js +3 -1
  51. package/dist/markdown.test.js +11 -3
  52. package/dist/message-formatting.js +18 -7
  53. package/dist/message-formatting.test.js +56 -9
  54. package/dist/oauth-rotation-shared.js +10 -0
  55. package/dist/openai-auth-plugin.js +1 -0
  56. package/dist/openai-auth-state.js +1 -0
  57. package/dist/opencode-command.test.js +60 -0
  58. package/dist/opencode.js +53 -12
  59. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  60. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  61. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  62. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  63. package/dist/queue-advanced-e2e-setup.js +78 -5
  64. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  65. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  66. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  67. package/dist/queue-advanced-question.e2e.test.js +31 -26
  68. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  69. package/dist/queue-delete-message.e2e.test.js +1 -1
  70. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  71. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  72. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  73. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  74. package/dist/schema.js +34 -0
  75. package/dist/session-handler/event-stream-state.js +267 -50
  76. package/dist/session-handler/event-stream-state.test.js +415 -8
  77. package/dist/session-handler/model-utils.js +159 -5
  78. package/dist/session-handler/model-utils.test.js +345 -0
  79. package/dist/session-handler/thread-session-runtime.js +252 -182
  80. package/dist/session-sleep.test.js +224 -0
  81. package/dist/session-title-rename.test.js +7 -39
  82. package/dist/sleep.e2e.test.js +491 -0
  83. package/dist/startup-time.e2e.test.js +2 -1
  84. package/dist/store.js +3 -0
  85. package/dist/system-message.js +59 -6
  86. package/dist/system-message.test.js +76 -6
  87. package/dist/task-runner.js +94 -4
  88. package/dist/task-schedule.js +56 -0
  89. package/dist/task-schedule.test.js +71 -1
  90. package/dist/thread-message-queue.e2e.test.js +10 -169
  91. package/dist/undo-redo.e2e.test.js +2 -2
  92. package/dist/voice-attachment.js +23 -1
  93. package/dist/voice-message.e2e.test.js +10 -10
  94. package/dist/voice.test.js +55 -29
  95. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  96. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  97. package/dist/worktrees.js +226 -443
  98. package/dist/worktrees.test.js +435 -58
  99. package/dist/xai-auth-plugin.js +1 -0
  100. package/dist/xai-auth-state.js +1 -0
  101. package/package.json +8 -6
  102. package/skills/errore/SKILL.md +18 -0
  103. package/skills/goke/SKILL.md +150 -10
  104. package/skills/holocron/SKILL.md +37 -0
  105. package/skills/npm-package/SKILL.md +55 -35
  106. package/skills/profano/SKILL.md +17 -2
  107. package/skills/spiceflow/SKILL.md +39 -3
  108. package/skills/strada/SKILL.md +39 -0
  109. package/skills/zele/SKILL.md +19 -0
  110. package/src/agent-model.e2e.test.ts +25 -151
  111. package/src/anthropic-account-identity.ts +1 -0
  112. package/src/anthropic-auth-plugin.ts +1 -0
  113. package/src/anthropic-auth-state.ts +1 -0
  114. package/src/bin.ts +78 -26
  115. package/src/cli-commands/maintenance.ts +59 -13
  116. package/src/cli-commands/send.ts +25 -0
  117. package/src/cli-commands/session.ts +203 -9
  118. package/src/cli-commands/task.ts +13 -1
  119. package/src/cli-parsing.test.ts +69 -3
  120. package/src/cli-runner.test.ts +49 -0
  121. package/src/cli-runner.ts +28 -4
  122. package/src/cli-send-thread.e2e.test.ts +1 -1
  123. package/src/cli.ts +53 -0
  124. package/src/commands/abort.ts +6 -1
  125. package/src/commands/agent.ts +6 -6
  126. package/src/commands/ask-question.test.ts +48 -1
  127. package/src/commands/ask-question.ts +21 -32
  128. package/src/commands/login.ts +6 -0
  129. package/src/commands/merge-worktree.ts +43 -12
  130. package/src/commands/model-variant.ts +8 -1
  131. package/src/commands/model.ts +18 -8
  132. package/src/commands/multioauth.ts +1 -0
  133. package/src/commands/new-worktree.ts +138 -55
  134. package/src/commands/user-command.ts +6 -6
  135. package/src/commands/worktrees.ts +5 -12
  136. package/src/config.ts +18 -1
  137. package/src/database.ts +195 -3
  138. package/src/db.test.ts +188 -0
  139. package/src/db.ts +26 -0
  140. package/src/discord-bot.ts +35 -29
  141. package/src/discord-command-registration.ts +15 -1
  142. package/src/discord-utils.test.ts +19 -0
  143. package/src/discord-utils.ts +21 -6
  144. package/src/errors.ts +5 -0
  145. package/src/exec-async.ts +20 -11
  146. package/src/external-directory-permissions.e2e.test.ts +2 -2
  147. package/src/file-edit-log.test.ts +287 -0
  148. package/src/file-edit-log.ts +283 -0
  149. package/src/format-tables.test.ts +101 -0
  150. package/src/format-tables.ts +75 -0
  151. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  152. package/src/gateway-proxy.e2e.test.ts +5 -5
  153. package/src/git-worktree-core.ts +247 -20
  154. package/src/ipc-tools-plugin.ts +75 -5
  155. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  156. package/src/kimaki-opencode-plugin.ts +7 -3
  157. package/src/kimaki-workspace-adaptor.ts +40 -11
  158. package/src/logger.ts +13 -4
  159. package/src/markdown.test.ts +13 -3
  160. package/src/markdown.ts +4 -1
  161. package/src/message-formatting.test.ts +86 -12
  162. package/src/message-formatting.ts +19 -7
  163. package/src/oauth-rotation-shared.ts +10 -0
  164. package/src/openai-auth-plugin.ts +1 -0
  165. package/src/openai-auth-state.ts +1 -0
  166. package/src/opencode-command.test.ts +81 -0
  167. package/src/opencode.ts +71 -11
  168. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  169. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  170. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  171. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  172. package/src/queue-advanced-e2e-setup.ts +83 -4
  173. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  174. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  175. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  176. package/src/queue-advanced-question.e2e.test.ts +35 -29
  177. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  178. package/src/queue-delete-message.e2e.test.ts +1 -1
  179. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  180. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  181. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  182. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  183. package/src/schema.sql +14 -1
  184. package/src/schema.ts +36 -0
  185. package/src/session-handler/event-stream-state.test.ts +437 -7
  186. package/src/session-handler/event-stream-state.ts +389 -53
  187. package/src/session-handler/model-utils.test.ts +415 -0
  188. package/src/session-handler/model-utils.ts +249 -5
  189. package/src/session-handler/thread-session-runtime.ts +300 -215
  190. package/src/session-sleep.test.ts +294 -0
  191. package/src/session-title-rename.test.ts +9 -48
  192. package/src/sleep.e2e.test.ts +562 -0
  193. package/src/startup-time.e2e.test.ts +5 -1
  194. package/src/store.ts +21 -0
  195. package/src/system-message.test.ts +86 -6
  196. package/src/system-message.ts +66 -7
  197. package/src/task-runner.ts +136 -2
  198. package/src/task-schedule.test.ts +76 -0
  199. package/src/task-schedule.ts +95 -0
  200. package/src/thread-message-queue.e2e.test.ts +10 -202
  201. package/src/undo-redo.e2e.test.ts +2 -2
  202. package/src/voice-attachment.ts +29 -1
  203. package/src/voice-message.e2e.test.ts +10 -10
  204. package/src/voice.test.ts +63 -29
  205. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  206. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  207. package/src/worktrees.test.ts +505 -64
  208. package/src/worktrees.ts +257 -603
  209. package/src/xai-auth-plugin.ts +1 -0
  210. package/src/xai-auth-state.ts +1 -0
  211. package/dist/message-finish-field.e2e.test.js +0 -169
  212. package/dist/subagent-rate-limit-plugin.js +0 -184
  213. package/src/message-finish-field.e2e.test.ts +0 -196
  214. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -0,0 +1,562 @@
1
+ // E2e coverage for the durable kimaki_sleep tool.
2
+ // Verifies the tool persists a wake row, the thread shows the sleep line,
3
+ // the task runner wake resumes the same session, and a user message cancels
4
+ // a planned sleep so the wake never fires.
5
+
6
+ import { describe, test, expect } from 'vitest'
7
+ import type { DeterministicMatcher } from 'opencode-deterministic-provider'
8
+ import {
9
+ setupQueueAdvancedSuite,
10
+ TEST_USER_ID,
11
+ } from './queue-advanced-e2e-setup.js'
12
+ import {
13
+ waitForBotMessageContaining,
14
+ waitForFooterMessage,
15
+ } from './test-utils.js'
16
+ import { getSessionSleep, getThreadSession } from './database.js'
17
+ import { wakeDueSessionSleeps } from './task-runner.js'
18
+
19
+ const TEXT_CHANNEL_ID = '200000000000001031'
20
+
21
+ // Fixed wake instant so the wake message stays snapshot-stable across runs.
22
+ const SLEEP_UNTIL = '2030-01-01T09:00:00Z'
23
+ const AFTER_SLEEP_UNTIL = new Date('2030-01-01T09:00:01Z')
24
+
25
+ function createSleepMatchers(): DeterministicMatcher[] {
26
+ const sleepCallMatcher: DeterministicMatcher = {
27
+ id: 'sleep-tool-call',
28
+ priority: 130,
29
+ when: {
30
+ lastMessageRole: 'user',
31
+ latestUserTextIncludes: 'SLEEP_TOOL_MARKER',
32
+ },
33
+ then: {
34
+ parts: [
35
+ { type: 'stream-start', warnings: [] },
36
+ { type: 'text-start', id: 'sleep-call-text' },
37
+ {
38
+ type: 'text-delta',
39
+ id: 'sleep-call-text',
40
+ delta: 'waiting for the deploy',
41
+ },
42
+ { type: 'text-end', id: 'sleep-call-text' },
43
+ {
44
+ type: 'tool-call',
45
+ toolCallId: 'sleep-call-1',
46
+ toolName: 'kimaki_sleep',
47
+ input: JSON.stringify({
48
+ until: SLEEP_UNTIL,
49
+ reason: 'waiting for the deploy',
50
+ }),
51
+ },
52
+ {
53
+ type: 'finish',
54
+ finishReason: 'tool-calls',
55
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
56
+ },
57
+ ],
58
+ },
59
+ }
60
+
61
+ const sleepAcknowledgeMatcher: DeterministicMatcher = {
62
+ id: 'sleep-tool-ack',
63
+ priority: 129,
64
+ when: {
65
+ latestUserTextIncludes: 'SLEEP_TOOL_MARKER',
66
+ rawPromptIncludes: 'Sleeping until',
67
+ },
68
+ then: {
69
+ parts: [
70
+ { type: 'stream-start', warnings: [] },
71
+ { type: 'text-start', id: 'sleep-ack-text' },
72
+ { type: 'text-delta', id: 'sleep-ack-text', delta: 'sleep-started' },
73
+ { type: 'text-end', id: 'sleep-ack-text' },
74
+ {
75
+ type: 'finish',
76
+ finishReason: 'stop',
77
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
78
+ },
79
+ ],
80
+ },
81
+ }
82
+
83
+ const sleepWakeMatcher: DeterministicMatcher = {
84
+ id: 'sleep-tool-wake',
85
+ priority: 131,
86
+ when: {
87
+ latestUserTextIncludes: 'Woke after sleeping until',
88
+ },
89
+ then: {
90
+ parts: [
91
+ { type: 'stream-start', warnings: [] },
92
+ { type: 'text-start', id: 'sleep-wake-text' },
93
+ { type: 'text-delta', id: 'sleep-wake-text', delta: 'sleep-wake-done' },
94
+ { type: 'text-end', id: 'sleep-wake-text' },
95
+ {
96
+ type: 'finish',
97
+ finishReason: 'stop',
98
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
99
+ },
100
+ ],
101
+ },
102
+ }
103
+
104
+ const sleepCancelCallMatcher: DeterministicMatcher = {
105
+ id: 'sleep-cancel-call',
106
+ priority: 132,
107
+ when: {
108
+ lastMessageRole: 'user',
109
+ latestUserTextIncludes: 'SLEEP_CANCEL_MARKER',
110
+ },
111
+ then: {
112
+ parts: [
113
+ { type: 'stream-start', warnings: [] },
114
+ {
115
+ type: 'tool-call',
116
+ toolCallId: 'sleep-cancel-call-1',
117
+ toolName: 'kimaki_sleep',
118
+ input: JSON.stringify({ duration: '2h', reason: 'cancel me' }),
119
+ },
120
+ {
121
+ type: 'finish',
122
+ finishReason: 'tool-calls',
123
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
124
+ },
125
+ ],
126
+ },
127
+ }
128
+
129
+ const sleepCancelAckMatcher: DeterministicMatcher = {
130
+ id: 'sleep-cancel-ack',
131
+ priority: 133,
132
+ when: {
133
+ latestUserTextIncludes: 'SLEEP_CANCEL_MARKER',
134
+ rawPromptIncludes: 'Sleeping until',
135
+ },
136
+ then: {
137
+ parts: [
138
+ { type: 'stream-start', warnings: [] },
139
+ { type: 'text-start', id: 'sleep-cancel-ack-text' },
140
+ {
141
+ type: 'text-delta',
142
+ id: 'sleep-cancel-ack-text',
143
+ delta: 'cancel-sleep-started',
144
+ },
145
+ { type: 'text-end', id: 'sleep-cancel-ack-text' },
146
+ {
147
+ type: 'finish',
148
+ finishReason: 'stop',
149
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
150
+ },
151
+ ],
152
+ },
153
+ }
154
+
155
+ const sleepQueueCallMatcher: DeterministicMatcher = {
156
+ id: 'sleep-queue-call',
157
+ priority: 135,
158
+ when: {
159
+ lastMessageRole: 'user',
160
+ latestUserTextIncludes: 'SLEEP_QUEUE_MARKER',
161
+ },
162
+ then: {
163
+ parts: [
164
+ { type: 'stream-start', warnings: [] },
165
+ {
166
+ type: 'tool-call',
167
+ toolCallId: 'sleep-queue-call-1',
168
+ toolName: 'kimaki_sleep',
169
+ input: JSON.stringify({ duration: '2h', reason: 'queue supersedes' }),
170
+ },
171
+ {
172
+ type: 'finish',
173
+ finishReason: 'tool-calls',
174
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
175
+ },
176
+ ],
177
+ },
178
+ }
179
+
180
+ const sleepQueueAckMatcher: DeterministicMatcher = {
181
+ id: 'sleep-queue-ack',
182
+ priority: 136,
183
+ when: {
184
+ latestUserTextIncludes: 'SLEEP_QUEUE_MARKER',
185
+ rawPromptIncludes: 'Sleeping until',
186
+ },
187
+ then: {
188
+ parts: [
189
+ { type: 'stream-start', warnings: [] },
190
+ { type: 'text-start', id: 'sleep-queue-ack-text' },
191
+ {
192
+ type: 'text-delta',
193
+ id: 'sleep-queue-ack-text',
194
+ delta: 'queue-sleep-started',
195
+ },
196
+ { type: 'text-end', id: 'sleep-queue-ack-text' },
197
+ {
198
+ type: 'finish',
199
+ finishReason: 'stop',
200
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
201
+ },
202
+ ],
203
+ },
204
+ }
205
+
206
+ const sleepQueuedMessageMatcher: DeterministicMatcher = {
207
+ id: 'sleep-queued-message',
208
+ priority: 137,
209
+ when: {
210
+ lastMessageRole: 'user',
211
+ latestUserTextIncludes: 'SLEEP_QUEUE_FOLLOWUP',
212
+ },
213
+ then: {
214
+ parts: [
215
+ { type: 'stream-start', warnings: [] },
216
+ { type: 'text-start', id: 'sleep-queued-text' },
217
+ {
218
+ type: 'text-delta',
219
+ id: 'sleep-queued-text',
220
+ delta: 'queue-followup-done',
221
+ },
222
+ { type: 'text-end', id: 'sleep-queued-text' },
223
+ {
224
+ type: 'finish',
225
+ finishReason: 'stop',
226
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
227
+ },
228
+ ],
229
+ },
230
+ }
231
+
232
+ const sleepCancelFollowupMatcher: DeterministicMatcher = {
233
+ id: 'sleep-cancel-followup',
234
+ priority: 134,
235
+ when: {
236
+ lastMessageRole: 'user',
237
+ latestUserTextIncludes: 'SLEEP_CANCEL_FOLLOWUP',
238
+ },
239
+ then: {
240
+ parts: [
241
+ { type: 'stream-start', warnings: [] },
242
+ { type: 'text-start', id: 'sleep-cancel-followup-text' },
243
+ {
244
+ type: 'text-delta',
245
+ id: 'sleep-cancel-followup-text',
246
+ delta: 'cancel-followup-done',
247
+ },
248
+ { type: 'text-end', id: 'sleep-cancel-followup-text' },
249
+ {
250
+ type: 'finish',
251
+ finishReason: 'stop',
252
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
253
+ },
254
+ ],
255
+ },
256
+ }
257
+
258
+ return [
259
+ sleepCallMatcher,
260
+ sleepAcknowledgeMatcher,
261
+ sleepWakeMatcher,
262
+ sleepCancelCallMatcher,
263
+ sleepCancelAckMatcher,
264
+ sleepCancelFollowupMatcher,
265
+ sleepQueueCallMatcher,
266
+ sleepQueueAckMatcher,
267
+ sleepQueuedMessageMatcher,
268
+ ]
269
+ }
270
+
271
+ describe('kimaki_sleep', () => {
272
+ const ctx = setupQueueAdvancedSuite({
273
+ channelId: TEXT_CHANNEL_ID,
274
+ channelName: 'sleep-e2e',
275
+ dirName: 'sleep-e2e',
276
+ username: 'sleep-tester',
277
+ extraMatchers: createSleepMatchers(),
278
+ })
279
+
280
+ test(
281
+ 'persists a wake row, then the runner wakes the same session',
282
+ async () => {
283
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
284
+ content: 'SLEEP_TOOL_MARKER wait for the deploy',
285
+ })
286
+
287
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
288
+ timeout: 4_000,
289
+ predicate: (t) => {
290
+ return t.name === 'SLEEP_TOOL_MARKER wait for the deploy'
291
+ },
292
+ })
293
+
294
+ const th = ctx.discord.thread(thread.id)
295
+
296
+ await waitForBotMessageContaining({
297
+ discord: ctx.discord,
298
+ threadId: thread.id,
299
+ text: 'sleep-started',
300
+ timeout: 4_000,
301
+ })
302
+
303
+ // Let the first turn finish emitting its footer before waking, otherwise
304
+ // the footer can land after the wake message and reorder the snapshot.
305
+ await waitForFooterMessage({
306
+ discord: ctx.discord,
307
+ threadId: thread.id,
308
+ timeout: 4_000,
309
+ afterMessageIncludes: 'sleep-started',
310
+ afterAuthorId: ctx.discord.botUserId,
311
+ })
312
+
313
+ const sessionId = await getThreadSession(thread.id)
314
+ if (!sessionId) {
315
+ throw new Error('Expected a thread session after the sleep tool ran')
316
+ }
317
+
318
+ const plannedSleep = await getSessionSleep({ sessionId })
319
+ expect(plannedSleep?.status).toBe('planned')
320
+ expect(plannedSleep?.reason).toBe('waiting for the deploy')
321
+
322
+ // The row is years out, so pass a future `now` instead of waiting on the tick.
323
+ await wakeDueSessionSleeps({
324
+ rest: ctx.botClient.rest,
325
+ now: AFTER_SLEEP_UNTIL,
326
+ })
327
+
328
+ await waitForBotMessageContaining({
329
+ discord: ctx.discord,
330
+ threadId: thread.id,
331
+ text: 'sleep-wake-done',
332
+ timeout: 4_000,
333
+ })
334
+
335
+ await waitForFooterMessage({
336
+ discord: ctx.discord,
337
+ threadId: thread.id,
338
+ timeout: 4_000,
339
+ afterMessageIncludes: 'sleep-wake-done',
340
+ afterAuthorId: ctx.discord.botUserId,
341
+ })
342
+
343
+ expect(await th.text()).toMatchInlineSnapshot(`
344
+ "--- from: user (sleep-tester)
345
+ SLEEP_TOOL_MARKER wait for the deploy
346
+ --- from: assistant (TestBot)
347
+ *using deterministic-provider/deterministic-v2*
348
+ ⬥ waiting for the deploy
349
+ ┣ kimaki_sleep until 2030-01-01T09:00:00Z _waiting for the deploy_
350
+ ⬥ sleep-started
351
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
352
+ ⬦ Woke after sleeping until 2030-01-01 09:00 UTC
353
+ Reason: waiting for the deploy
354
+ Continue the work you were waiting for.
355
+ [embed]
356
+ ⬥ sleep-wake-done
357
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
358
+ `)
359
+
360
+ // The wake must continue the SAME OpenCode session, not start a new one:
361
+ // that is the whole point of sleeping instead of scheduling a new prompt.
362
+ expect(await getThreadSession(thread.id)).toBe(sessionId)
363
+
364
+ // `consumed` proves ingress actually turned the wake into a turn. The row
365
+ // only reaches this state through the ingress commit point.
366
+ const wokenSleep = await getSessionSleep({ sessionId })
367
+ expect(wokenSleep?.status).toBe('consumed')
368
+
369
+ // Replaying the tick must not produce a second wake turn.
370
+ const messagesBeforeReplay = (await th.getMessages()).length
371
+ await wakeDueSessionSleeps({
372
+ rest: ctx.botClient.rest,
373
+ now: AFTER_SLEEP_UNTIL,
374
+ })
375
+ for (let attempt = 0; attempt < 10; attempt++) {
376
+ expect((await th.getMessages()).length).toBe(messagesBeforeReplay)
377
+ await new Promise((resolve) => {
378
+ setTimeout(resolve, 20)
379
+ })
380
+ }
381
+ },
382
+ 20_000,
383
+ )
384
+
385
+ test(
386
+ 'a user message cancels a planned sleep so no wake fires',
387
+ async () => {
388
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
389
+ content: 'SLEEP_CANCEL_MARKER wait for something',
390
+ })
391
+
392
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
393
+ timeout: 4_000,
394
+ predicate: (t) => {
395
+ return t.name === 'SLEEP_CANCEL_MARKER wait for something'
396
+ },
397
+ })
398
+
399
+ const th = ctx.discord.thread(thread.id)
400
+
401
+ await waitForBotMessageContaining({
402
+ discord: ctx.discord,
403
+ threadId: thread.id,
404
+ text: 'cancel-sleep-started',
405
+ timeout: 4_000,
406
+ })
407
+
408
+ await waitForFooterMessage({
409
+ discord: ctx.discord,
410
+ threadId: thread.id,
411
+ timeout: 4_000,
412
+ afterMessageIncludes: 'cancel-sleep-started',
413
+ afterAuthorId: ctx.discord.botUserId,
414
+ })
415
+
416
+ const sessionId = await getThreadSession(thread.id)
417
+ if (!sessionId) {
418
+ throw new Error('Expected a thread session after the sleep tool ran')
419
+ }
420
+ expect((await getSessionSleep({ sessionId }))?.status).toBe('planned')
421
+
422
+ await th.user(TEST_USER_ID).sendMessage({
423
+ content: 'SLEEP_CANCEL_FOLLOWUP never mind, keep going',
424
+ })
425
+
426
+ await waitForBotMessageContaining({
427
+ discord: ctx.discord,
428
+ threadId: thread.id,
429
+ text: 'cancel-followup-done',
430
+ timeout: 4_000,
431
+ })
432
+
433
+ await waitForFooterMessage({
434
+ discord: ctx.discord,
435
+ threadId: thread.id,
436
+ timeout: 4_000,
437
+ afterMessageIncludes: 'cancel-followup-done',
438
+ afterAuthorId: ctx.discord.botUserId,
439
+ })
440
+
441
+ // Cancellation happens inside runtime.enqueueIncoming, so it covers
442
+ // slash commands and CLI-injected prompts, not just chat messages.
443
+ expect((await getSessionSleep({ sessionId }))?.status).toBe('cancelled')
444
+
445
+ await wakeDueSessionSleeps({
446
+ rest: ctx.botClient.rest,
447
+ now: AFTER_SLEEP_UNTIL,
448
+ })
449
+
450
+ // Everything is deterministic, so a short poll proves the wake never posts.
451
+ for (let attempt = 0; attempt < 10; attempt++) {
452
+ const messages = await th.getMessages()
453
+ const woke = messages.some((message) => {
454
+ return message.content.includes('Woke after sleeping until')
455
+ })
456
+ expect(woke).toBe(false)
457
+ await new Promise((resolve) => {
458
+ setTimeout(resolve, 20)
459
+ })
460
+ }
461
+
462
+ expect(await th.text()).toMatchInlineSnapshot(`
463
+ "--- from: user (sleep-tester)
464
+ SLEEP_CANCEL_MARKER wait for something
465
+ --- from: assistant (TestBot)
466
+ *using deterministic-provider/deterministic-v2*
467
+ ┣ kimaki_sleep for 2h _cancel me_
468
+ ⬥ cancel-sleep-started
469
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
470
+ --- from: user (sleep-tester)
471
+ SLEEP_CANCEL_FOLLOWUP never mind, keep going
472
+ --- from: assistant (TestBot)
473
+ ⬥ cancel-followup-done
474
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
475
+ `)
476
+ },
477
+ 20_000,
478
+ )
479
+
480
+ test(
481
+ '/queue also supersedes a pending sleep',
482
+ async () => {
483
+ // Slash commands never reach the Discord MessageCreate handler, so this
484
+ // proves cancellation really lives in the shared ingress path.
485
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
486
+ content: 'SLEEP_QUEUE_MARKER wait for something',
487
+ })
488
+
489
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
490
+ timeout: 4_000,
491
+ predicate: (t) => {
492
+ return t.name === 'SLEEP_QUEUE_MARKER wait for something'
493
+ },
494
+ })
495
+
496
+ const th = ctx.discord.thread(thread.id)
497
+
498
+ await waitForBotMessageContaining({
499
+ discord: ctx.discord,
500
+ threadId: thread.id,
501
+ text: 'queue-sleep-started',
502
+ timeout: 4_000,
503
+ })
504
+
505
+ await waitForFooterMessage({
506
+ discord: ctx.discord,
507
+ threadId: thread.id,
508
+ timeout: 4_000,
509
+ afterMessageIncludes: 'queue-sleep-started',
510
+ afterAuthorId: ctx.discord.botUserId,
511
+ })
512
+
513
+ const sessionId = await getThreadSession(thread.id)
514
+ if (!sessionId) {
515
+ throw new Error('Expected a thread session after the sleep tool ran')
516
+ }
517
+ expect((await getSessionSleep({ sessionId }))?.status).toBe('planned')
518
+
519
+ const { id: queueInteractionId } = await th.user(TEST_USER_ID)
520
+ .runSlashCommand({
521
+ name: 'queue',
522
+ options: [
523
+ {
524
+ name: 'message',
525
+ type: 3,
526
+ value: 'SLEEP_QUEUE_FOLLOWUP handle this instead',
527
+ },
528
+ ],
529
+ })
530
+ await th.waitForInteractionAck({
531
+ interactionId: queueInteractionId,
532
+ timeout: 4_000,
533
+ })
534
+
535
+ await waitForBotMessageContaining({
536
+ discord: ctx.discord,
537
+ threadId: thread.id,
538
+ text: 'queue-followup-done',
539
+ timeout: 4_000,
540
+ })
541
+
542
+ expect((await getSessionSleep({ sessionId }))?.status).toBe('cancelled')
543
+
544
+ await wakeDueSessionSleeps({
545
+ rest: ctx.botClient.rest,
546
+ now: AFTER_SLEEP_UNTIL,
547
+ })
548
+
549
+ for (let attempt = 0; attempt < 10; attempt++) {
550
+ const messages = await th.getMessages()
551
+ const woke = messages.some((message) => {
552
+ return message.content.includes('Woke after sleeping until')
553
+ })
554
+ expect(woke).toBe(false)
555
+ await new Promise((resolve) => {
556
+ setTimeout(resolve, 20)
557
+ })
558
+ }
559
+ },
560
+ 20_000,
561
+ )
562
+ })
@@ -106,7 +106,11 @@ function createMinimalMatchers(): DeterministicMatcher[] {
106
106
  const TEST_USER_ID = '900000000000000777'
107
107
  const TEXT_CHANNEL_ID = '900000000000000778'
108
108
 
109
- describe('startup time measurement', () => {
109
+ const startupBenchmark = describe.skipIf(
110
+ process.env['KIMAKI_STARTUP_BENCHMARK'] !== '1',
111
+ )
112
+
113
+ startupBenchmark('startup time measurement', () => {
110
114
  let directories: ReturnType<typeof createRunDirectories>
111
115
  let discord: DigitalDiscord
112
116
  let botClient: Client | null = null
package/src/store.ts CHANGED
@@ -67,6 +67,11 @@ export type KimakiState = {
67
67
  // Read by: system-message.ts (conditionally appends critique instructions).
68
68
  critiqueEnabled: boolean
69
69
 
70
+ // Whether final session footers mention the thread creator.
71
+ // Changes: set once at startup from --skip-footer-mentions.
72
+ // Read by: ThreadSessionRuntime.emitFooter().
73
+ footerMentionsEnabled: boolean
74
+
70
75
  // User-specified skill whitelist. When non-empty, only these skill names
71
76
  // are injected into the model's system prompt (all others are hidden
72
77
  // behind an opencode permission.skill deny-all rule). Mutually exclusive
@@ -96,6 +101,19 @@ export type KimakiState = {
96
101
  // Read by: discord-utils.ts hasKimakiBotPermission().
97
102
  allowAllUsers: boolean
98
103
 
104
+ // Hostname passed to `opencode serve --hostname`. Null means OpenCode's
105
+ // default (127.0.0.1). Set from --opencode-hostname. Kimaki still talks
106
+ // to 127.0.0.1 even when the server binds 0.0.0.0. Does not bind the
107
+ // Kimaki hrana/lock server.
108
+ // Changes: set once at startup from --opencode-hostname CLI flag.
109
+ // Read by: opencode.ts startSingleServer().
110
+ opencodeHostname: string | null
111
+
112
+ // Port passed to `opencode serve --port`. Null means pick a free port.
113
+ // Changes: set once at startup from --opencode-port CLI flag.
114
+ // Read by: opencode.ts startSingleServer().
115
+ opencodePort: number | null
116
+
99
117
  // When true, the agent may only touch the session working directory and a
100
118
  // small set of known-safe paths; anything else triggers an external_directory
101
119
  // permission prompt. When false (default), every directory is allowed and the
@@ -185,10 +203,13 @@ export const store = createStore<KimakiState>(() => ({
185
203
  defaultVerbosity: 'text_and_essential_tools',
186
204
  defaultMentionMode: false,
187
205
  critiqueEnabled: true,
206
+ footerMentionsEnabled: true,
188
207
  enabledSkills: [],
189
208
  disabledSkills: [],
190
209
  allowedMentions: ['users'],
191
210
  allowAllUsers: false,
211
+ opencodeHostname: null,
212
+ opencodePort: null,
192
213
  restrictExternalDirectories: false,
193
214
  permissionTimeoutMs: 10 * 60 * 1000,
194
215
  useWorktrees: false,