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
@@ -2,7 +2,7 @@
2
2
  // decides whether (and how) to rename a Discord thread based on an
3
3
  // OpenCode session title. Kept focused and deterministic; no Discord mocks.
4
4
  import { describe, test, expect } from 'vitest';
5
- import { deriveThreadNameFromSessionTitle, deriveThreadRenameFromSessionUpdate, } from './session-handler/thread-session-runtime.js';
5
+ import { deriveThreadNameFromSessionTitle, } from './session-handler/thread-session-runtime.js';
6
6
  describe('deriveThreadNameFromSessionTitle', () => {
7
7
  test('returns trimmed title for plain thread', () => {
8
8
  expect(deriveThreadNameFromSessionTitle({
@@ -67,6 +67,30 @@ describe('deriveThreadNameFromSessionTitle', () => {
67
67
  currentName: 'seed',
68
68
  })).toMatchInlineSnapshot(`undefined`);
69
69
  });
70
+ test('ignores a title containing only OpenCode callout markup', () => {
71
+ expect(deriveThreadNameFromSessionTitle({
72
+ sessionTitle: '<callout accent="#ef4444">',
73
+ currentName: 'Useful existing title',
74
+ })).toMatchInlineSnapshot(`undefined`);
75
+ });
76
+ test('ignores paired OpenCode callout markup with no title text', () => {
77
+ expect(deriveThreadNameFromSessionTitle({
78
+ sessionTitle: '<callout accent="#ef4444"></callout>',
79
+ currentName: 'Useful existing title',
80
+ })).toMatchInlineSnapshot(`undefined`);
81
+ });
82
+ test('keeps useful text while removing OpenCode callout wrappers', () => {
83
+ expect(deriveThreadNameFromSessionTitle({
84
+ sessionTitle: '<callout accent="#ef4444">Useful title</callout>',
85
+ currentName: 'seed',
86
+ })).toMatchInlineSnapshot(`"Useful title"`);
87
+ });
88
+ test('preserves a thread prefix around a multiline uppercase callout', () => {
89
+ expect(deriveThreadNameFromSessionTitle({
90
+ sessionTitle: '<CALLOUT icon="info">\nUseful title\n</CALLOUT>',
91
+ currentName: 'btw: original title',
92
+ })).toMatchInlineSnapshot(`"btw: Useful title"`);
93
+ });
70
94
  test('preserves btw: prefix from current name', () => {
71
95
  expect(deriveThreadNameFromSessionTitle({
72
96
  sessionTitle: 'Side question about auth',
@@ -79,6 +103,18 @@ describe('deriveThreadNameFromSessionTitle', () => {
79
103
  currentName: 'Fork: old session title',
80
104
  })).toMatchInlineSnapshot(`"Fork: Forked task title"`);
81
105
  });
106
+ test('does not double a worktree prefix copied from the Discord thread name', () => {
107
+ expect(deriveThreadNameFromSessionTitle({
108
+ sessionTitle: '⬦ Fix queue draining',
109
+ currentName: '⬦ Old name',
110
+ })).toMatchInlineSnapshot(`"⬦ Fix queue draining"`);
111
+ });
112
+ test('preserves the worktree prefix', () => {
113
+ expect(deriveThreadNameFromSessionTitle({
114
+ sessionTitle: 'Refactor queue',
115
+ currentName: '⬦ old worktree title',
116
+ })).toMatchInlineSnapshot(`"⬦ Refactor queue"`);
117
+ });
82
118
  test('returns undefined for null/undefined title', () => {
83
119
  expect(deriveThreadNameFromSessionTitle({
84
120
  sessionTitle: null,
@@ -90,41 +126,3 @@ describe('deriveThreadNameFromSessionTitle', () => {
90
126
  })).toMatchInlineSnapshot(`undefined`);
91
127
  });
92
128
  });
93
- describe('deriveThreadRenameFromSessionUpdate', () => {
94
- test('skips auto-rename after the thread differs from the persisted synced name', () => {
95
- expect(deriveThreadRenameFromSessionUpdate({
96
- sessionTitle: 'New OpenCode title',
97
- currentName: 'custom name from user',
98
- lastSyncedName: 'Old OpenCode title',
99
- })).toMatchInlineSnapshot(`
100
- {
101
- "desiredName": null,
102
- "nextSyncedName": "Old OpenCode title",
103
- }
104
- `);
105
- });
106
- test('returns desired name while thread still matches the persisted synced name', () => {
107
- expect(deriveThreadRenameFromSessionUpdate({
108
- sessionTitle: 'New OpenCode title',
109
- currentName: 'Old OpenCode title',
110
- lastSyncedName: 'Old OpenCode title',
111
- })).toMatchInlineSnapshot(`
112
- {
113
- "desiredName": "New OpenCode title",
114
- "nextSyncedName": "New OpenCode title",
115
- }
116
- `);
117
- });
118
- test('remembers a no-op matching title as synced for later manual rename detection', () => {
119
- expect(deriveThreadRenameFromSessionUpdate({
120
- sessionTitle: 'Old OpenCode title',
121
- currentName: 'Old OpenCode title',
122
- lastSyncedName: null,
123
- })).toMatchInlineSnapshot(`
124
- {
125
- "desiredName": null,
126
- "nextSyncedName": "Old OpenCode title",
127
- }
128
- `);
129
- });
130
- });
@@ -0,0 +1,494 @@
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
+ import { describe, test, expect } from 'vitest';
6
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
7
+ import { waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
8
+ import { getSessionSleep, getThreadSession } from './database.js';
9
+ import { wakeDueSessionSleeps } from './task-runner.js';
10
+ const TEXT_CHANNEL_ID = '200000000000001031';
11
+ // Fixed wake instant so the wake message stays snapshot-stable across runs.
12
+ const SLEEP_UNTIL = '2030-01-01T09:00:00Z';
13
+ const AFTER_SLEEP_UNTIL = new Date('2030-01-01T09:00:01Z');
14
+ function createSleepMatchers() {
15
+ const sleepCallMatcher = {
16
+ id: 'sleep-tool-call',
17
+ priority: 130,
18
+ when: {
19
+ lastMessageRole: 'user',
20
+ latestUserTextIncludes: 'SLEEP_TOOL_MARKER',
21
+ },
22
+ then: {
23
+ parts: [
24
+ { type: 'stream-start', warnings: [] },
25
+ { type: 'text-start', id: 'sleep-call-text' },
26
+ {
27
+ type: 'text-delta',
28
+ id: 'sleep-call-text',
29
+ delta: 'waiting for the deploy',
30
+ },
31
+ { type: 'text-end', id: 'sleep-call-text' },
32
+ {
33
+ type: 'tool-call',
34
+ toolCallId: 'sleep-call-1',
35
+ toolName: 'kimaki_sleep',
36
+ input: JSON.stringify({
37
+ until: SLEEP_UNTIL,
38
+ reason: 'waiting for the deploy',
39
+ }),
40
+ },
41
+ {
42
+ type: 'finish',
43
+ finishReason: 'tool-calls',
44
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
45
+ },
46
+ ],
47
+ },
48
+ };
49
+ const sleepAcknowledgeMatcher = {
50
+ id: 'sleep-tool-ack',
51
+ priority: 129,
52
+ when: {
53
+ latestUserTextIncludes: 'SLEEP_TOOL_MARKER',
54
+ rawPromptIncludes: 'Sleeping until',
55
+ },
56
+ then: {
57
+ parts: [
58
+ { type: 'stream-start', warnings: [] },
59
+ { type: 'text-start', id: 'sleep-ack-text' },
60
+ { type: 'text-delta', id: 'sleep-ack-text', delta: 'sleep-started' },
61
+ { type: 'text-end', id: 'sleep-ack-text' },
62
+ {
63
+ type: 'finish',
64
+ finishReason: 'stop',
65
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
66
+ },
67
+ ],
68
+ },
69
+ };
70
+ const sleepWakeMatcher = {
71
+ id: 'sleep-tool-wake',
72
+ priority: 131,
73
+ when: {
74
+ latestUserTextIncludes: 'Woke after sleeping until',
75
+ },
76
+ then: {
77
+ parts: [
78
+ { type: 'stream-start', warnings: [] },
79
+ { type: 'text-start', id: 'sleep-wake-text' },
80
+ { type: 'text-delta', id: 'sleep-wake-text', delta: 'sleep-wake-done' },
81
+ { type: 'text-end', id: 'sleep-wake-text' },
82
+ {
83
+ type: 'finish',
84
+ finishReason: 'stop',
85
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
86
+ },
87
+ ],
88
+ },
89
+ };
90
+ const sleepCancelCallMatcher = {
91
+ id: 'sleep-cancel-call',
92
+ priority: 132,
93
+ when: {
94
+ lastMessageRole: 'user',
95
+ latestUserTextIncludes: 'SLEEP_CANCEL_MARKER',
96
+ },
97
+ then: {
98
+ parts: [
99
+ { type: 'stream-start', warnings: [] },
100
+ {
101
+ type: 'tool-call',
102
+ toolCallId: 'sleep-cancel-call-1',
103
+ toolName: 'kimaki_sleep',
104
+ input: JSON.stringify({ duration: '2h', reason: 'cancel me' }),
105
+ },
106
+ {
107
+ type: 'finish',
108
+ finishReason: 'tool-calls',
109
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
110
+ },
111
+ ],
112
+ },
113
+ };
114
+ const sleepCancelAckMatcher = {
115
+ id: 'sleep-cancel-ack',
116
+ priority: 133,
117
+ when: {
118
+ latestUserTextIncludes: 'SLEEP_CANCEL_MARKER',
119
+ rawPromptIncludes: 'Sleeping until',
120
+ },
121
+ then: {
122
+ parts: [
123
+ { type: 'stream-start', warnings: [] },
124
+ { type: 'text-start', id: 'sleep-cancel-ack-text' },
125
+ {
126
+ type: 'text-delta',
127
+ id: 'sleep-cancel-ack-text',
128
+ delta: 'cancel-sleep-started',
129
+ },
130
+ { type: 'text-end', id: 'sleep-cancel-ack-text' },
131
+ {
132
+ type: 'finish',
133
+ finishReason: 'stop',
134
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
135
+ },
136
+ ],
137
+ },
138
+ };
139
+ const sleepQueueCallMatcher = {
140
+ id: 'sleep-queue-call',
141
+ priority: 135,
142
+ when: {
143
+ lastMessageRole: 'user',
144
+ latestUserTextIncludes: 'SLEEP_QUEUE_MARKER',
145
+ },
146
+ then: {
147
+ parts: [
148
+ { type: 'stream-start', warnings: [] },
149
+ {
150
+ type: 'tool-call',
151
+ toolCallId: 'sleep-queue-call-1',
152
+ toolName: 'kimaki_sleep',
153
+ input: JSON.stringify({ duration: '2h', reason: 'queue supersedes' }),
154
+ },
155
+ {
156
+ type: 'finish',
157
+ finishReason: 'tool-calls',
158
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
159
+ },
160
+ ],
161
+ },
162
+ };
163
+ const sleepQueueAckMatcher = {
164
+ id: 'sleep-queue-ack',
165
+ priority: 136,
166
+ when: {
167
+ latestUserTextIncludes: 'SLEEP_QUEUE_MARKER',
168
+ rawPromptIncludes: 'Sleeping until',
169
+ },
170
+ then: {
171
+ parts: [
172
+ { type: 'stream-start', warnings: [] },
173
+ { type: 'text-start', id: 'sleep-queue-ack-text' },
174
+ {
175
+ type: 'text-delta',
176
+ id: 'sleep-queue-ack-text',
177
+ delta: 'queue-sleep-started',
178
+ },
179
+ { type: 'text-end', id: 'sleep-queue-ack-text' },
180
+ {
181
+ type: 'finish',
182
+ finishReason: 'stop',
183
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
184
+ },
185
+ ],
186
+ },
187
+ };
188
+ const sleepQueuedMessageMatcher = {
189
+ id: 'sleep-queued-message',
190
+ priority: 137,
191
+ when: {
192
+ lastMessageRole: 'user',
193
+ latestUserTextIncludes: 'SLEEP_QUEUE_FOLLOWUP',
194
+ },
195
+ then: {
196
+ parts: [
197
+ { type: 'stream-start', warnings: [] },
198
+ { type: 'text-start', id: 'sleep-queued-text' },
199
+ {
200
+ type: 'text-delta',
201
+ id: 'sleep-queued-text',
202
+ delta: 'queue-followup-done',
203
+ },
204
+ { type: 'text-end', id: 'sleep-queued-text' },
205
+ {
206
+ type: 'finish',
207
+ finishReason: 'stop',
208
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
209
+ },
210
+ ],
211
+ },
212
+ };
213
+ const sleepCancelFollowupMatcher = {
214
+ id: 'sleep-cancel-followup',
215
+ priority: 134,
216
+ when: {
217
+ lastMessageRole: 'user',
218
+ latestUserTextIncludes: 'SLEEP_CANCEL_FOLLOWUP',
219
+ },
220
+ then: {
221
+ parts: [
222
+ { type: 'stream-start', warnings: [] },
223
+ { type: 'text-start', id: 'sleep-cancel-followup-text' },
224
+ {
225
+ type: 'text-delta',
226
+ id: 'sleep-cancel-followup-text',
227
+ delta: 'cancel-followup-done',
228
+ },
229
+ { type: 'text-end', id: 'sleep-cancel-followup-text' },
230
+ {
231
+ type: 'finish',
232
+ finishReason: 'stop',
233
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
234
+ },
235
+ ],
236
+ },
237
+ };
238
+ return [
239
+ sleepCallMatcher,
240
+ sleepAcknowledgeMatcher,
241
+ sleepWakeMatcher,
242
+ sleepCancelCallMatcher,
243
+ sleepCancelAckMatcher,
244
+ sleepCancelFollowupMatcher,
245
+ sleepQueueCallMatcher,
246
+ sleepQueueAckMatcher,
247
+ sleepQueuedMessageMatcher,
248
+ ];
249
+ }
250
+ describe('kimaki_sleep', () => {
251
+ const ctx = setupQueueAdvancedSuite({
252
+ channelId: TEXT_CHANNEL_ID,
253
+ channelName: 'sleep-e2e',
254
+ dirName: 'sleep-e2e',
255
+ username: 'sleep-tester',
256
+ extraMatchers: createSleepMatchers(),
257
+ });
258
+ test('persists a wake row, then the runner wakes the same session', async () => {
259
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
260
+ content: 'SLEEP_TOOL_MARKER wait for the deploy',
261
+ });
262
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
263
+ timeout: 4_000,
264
+ predicate: (t) => {
265
+ return t.name === 'SLEEP_TOOL_MARKER wait for the deploy';
266
+ },
267
+ });
268
+ const th = ctx.discord.thread(thread.id);
269
+ await waitForBotMessageContaining({
270
+ discord: ctx.discord,
271
+ threadId: thread.id,
272
+ text: 'sleep-started',
273
+ timeout: 4_000,
274
+ });
275
+ // Let the first turn finish emitting its footer before waking, otherwise
276
+ // the footer can land after the wake message and reorder the snapshot.
277
+ await waitForFooterMessage({
278
+ discord: ctx.discord,
279
+ threadId: thread.id,
280
+ timeout: 4_000,
281
+ afterMessageIncludes: 'sleep-started',
282
+ afterAuthorId: ctx.discord.botUserId,
283
+ });
284
+ const sessionId = await getThreadSession(thread.id);
285
+ if (!sessionId) {
286
+ throw new Error('Expected a thread session after the sleep tool ran');
287
+ }
288
+ const plannedSleep = await getSessionSleep({ sessionId });
289
+ expect(plannedSleep?.status).toBe('planned');
290
+ expect(plannedSleep?.reason).toBe('waiting for the deploy');
291
+ // The row is years out, so pass a future `now` instead of waiting on the tick.
292
+ await wakeDueSessionSleeps({
293
+ rest: ctx.botClient.rest,
294
+ now: AFTER_SLEEP_UNTIL,
295
+ });
296
+ await waitForBotMessageContaining({
297
+ discord: ctx.discord,
298
+ threadId: thread.id,
299
+ text: 'sleep-wake-done',
300
+ timeout: 4_000,
301
+ });
302
+ await waitForFooterMessage({
303
+ discord: ctx.discord,
304
+ threadId: thread.id,
305
+ timeout: 4_000,
306
+ afterMessageIncludes: 'sleep-wake-done',
307
+ afterAuthorId: ctx.discord.botUserId,
308
+ });
309
+ expect(await th.text()).toMatchInlineSnapshot(`
310
+ "--- from: user (sleep-tester)
311
+ SLEEP_TOOL_MARKER wait for the deploy
312
+ --- from: assistant (TestBot)
313
+ > *using deterministic-provider/deterministic-v2*
314
+ > waiting for the deploy
315
+
316
+ ┣ kimaki_sleep until 2030-01-01T09:00:00Z _waiting for the deploy_
317
+
318
+ > sleep-started
319
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2*
320
+ ⬦ Woke after sleeping until 2030-01-01 09:00 UTC
321
+ Reason: waiting for the deploy
322
+ Continue the work you were waiting for.
323
+ [embed]
324
+ > sleep-wake-done
325
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
326
+ `);
327
+ // The wake must continue the SAME OpenCode session, not start a new one:
328
+ // that is the whole point of sleeping instead of scheduling a new prompt.
329
+ expect(await getThreadSession(thread.id)).toBe(sessionId);
330
+ // `consumed` proves ingress actually turned the wake into a turn. The row
331
+ // only reaches this state through the ingress commit point.
332
+ const wokenSleep = await getSessionSleep({ sessionId });
333
+ expect(wokenSleep?.status).toBe('consumed');
334
+ // Replaying the tick must not produce a second wake turn.
335
+ const messagesBeforeReplay = (await th.getMessages()).length;
336
+ await wakeDueSessionSleeps({
337
+ rest: ctx.botClient.rest,
338
+ now: AFTER_SLEEP_UNTIL,
339
+ });
340
+ for (let attempt = 0; attempt < 10; attempt++) {
341
+ expect((await th.getMessages()).length).toBe(messagesBeforeReplay);
342
+ await new Promise((resolve) => {
343
+ setTimeout(resolve, 20);
344
+ });
345
+ }
346
+ }, 20_000);
347
+ test('a user message cancels a planned sleep so no wake fires', async () => {
348
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
349
+ content: 'SLEEP_CANCEL_MARKER wait for something',
350
+ });
351
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
352
+ timeout: 4_000,
353
+ predicate: (t) => {
354
+ return t.name === 'SLEEP_CANCEL_MARKER wait for something';
355
+ },
356
+ });
357
+ const th = ctx.discord.thread(thread.id);
358
+ await waitForBotMessageContaining({
359
+ discord: ctx.discord,
360
+ threadId: thread.id,
361
+ text: 'cancel-sleep-started',
362
+ timeout: 4_000,
363
+ });
364
+ await waitForFooterMessage({
365
+ discord: ctx.discord,
366
+ threadId: thread.id,
367
+ timeout: 4_000,
368
+ afterMessageIncludes: 'cancel-sleep-started',
369
+ afterAuthorId: ctx.discord.botUserId,
370
+ });
371
+ const sessionId = await getThreadSession(thread.id);
372
+ if (!sessionId) {
373
+ throw new Error('Expected a thread session after the sleep tool ran');
374
+ }
375
+ expect((await getSessionSleep({ sessionId }))?.status).toBe('planned');
376
+ await th.user(TEST_USER_ID).sendMessage({
377
+ content: 'SLEEP_CANCEL_FOLLOWUP never mind, keep going',
378
+ });
379
+ await waitForBotMessageContaining({
380
+ discord: ctx.discord,
381
+ threadId: thread.id,
382
+ text: 'cancel-followup-done',
383
+ timeout: 4_000,
384
+ });
385
+ await waitForFooterMessage({
386
+ discord: ctx.discord,
387
+ threadId: thread.id,
388
+ timeout: 4_000,
389
+ afterMessageIncludes: 'cancel-followup-done',
390
+ afterAuthorId: ctx.discord.botUserId,
391
+ });
392
+ // Cancellation happens inside runtime.enqueueIncoming, so it covers
393
+ // slash commands and CLI-injected prompts, not just chat messages.
394
+ expect((await getSessionSleep({ sessionId }))?.status).toBe('cancelled');
395
+ await wakeDueSessionSleeps({
396
+ rest: ctx.botClient.rest,
397
+ now: AFTER_SLEEP_UNTIL,
398
+ });
399
+ // Everything is deterministic, so a short poll proves the wake never posts.
400
+ for (let attempt = 0; attempt < 10; attempt++) {
401
+ const messages = await th.getMessages();
402
+ const woke = messages.some((message) => {
403
+ return message.content.includes('Woke after sleeping until');
404
+ });
405
+ expect(woke).toBe(false);
406
+ await new Promise((resolve) => {
407
+ setTimeout(resolve, 20);
408
+ });
409
+ }
410
+ expect(await th.text()).toMatchInlineSnapshot(`
411
+ "--- from: user (sleep-tester)
412
+ SLEEP_CANCEL_MARKER wait for something
413
+ --- from: assistant (TestBot)
414
+ > *using deterministic-provider/deterministic-v2*
415
+ ┣ kimaki_sleep for 2h _cancel me_
416
+
417
+ > cancel-sleep-started
418
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2*
419
+ --- from: user (sleep-tester)
420
+ SLEEP_CANCEL_FOLLOWUP never mind, keep going
421
+ --- from: assistant (TestBot)
422
+ > cancel-followup-done
423
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
424
+ `);
425
+ }, 20_000);
426
+ test('/queue also supersedes a pending sleep', async () => {
427
+ // Slash commands never reach the Discord MessageCreate handler, so this
428
+ // proves cancellation really lives in the shared ingress path.
429
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
430
+ content: 'SLEEP_QUEUE_MARKER wait for something',
431
+ });
432
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
433
+ timeout: 4_000,
434
+ predicate: (t) => {
435
+ return t.name === 'SLEEP_QUEUE_MARKER wait for something';
436
+ },
437
+ });
438
+ const th = ctx.discord.thread(thread.id);
439
+ await waitForBotMessageContaining({
440
+ discord: ctx.discord,
441
+ threadId: thread.id,
442
+ text: 'queue-sleep-started',
443
+ timeout: 4_000,
444
+ });
445
+ await waitForFooterMessage({
446
+ discord: ctx.discord,
447
+ threadId: thread.id,
448
+ timeout: 4_000,
449
+ afterMessageIncludes: 'queue-sleep-started',
450
+ afterAuthorId: ctx.discord.botUserId,
451
+ });
452
+ const sessionId = await getThreadSession(thread.id);
453
+ if (!sessionId) {
454
+ throw new Error('Expected a thread session after the sleep tool ran');
455
+ }
456
+ expect((await getSessionSleep({ sessionId }))?.status).toBe('planned');
457
+ const { id: queueInteractionId } = await th.user(TEST_USER_ID)
458
+ .runSlashCommand({
459
+ name: 'queue',
460
+ options: [
461
+ {
462
+ name: 'message',
463
+ type: 3,
464
+ value: 'SLEEP_QUEUE_FOLLOWUP handle this instead',
465
+ },
466
+ ],
467
+ });
468
+ await th.waitForInteractionAck({
469
+ interactionId: queueInteractionId,
470
+ timeout: 4_000,
471
+ });
472
+ await waitForBotMessageContaining({
473
+ discord: ctx.discord,
474
+ threadId: thread.id,
475
+ text: 'queue-followup-done',
476
+ timeout: 4_000,
477
+ });
478
+ expect((await getSessionSleep({ sessionId }))?.status).toBe('cancelled');
479
+ await wakeDueSessionSleeps({
480
+ rest: ctx.botClient.rest,
481
+ now: AFTER_SLEEP_UNTIL,
482
+ });
483
+ for (let attempt = 0; attempt < 10; attempt++) {
484
+ const messages = await th.getMessages();
485
+ const woke = messages.some((message) => {
486
+ return message.content.includes('Woke after sleeping until');
487
+ });
488
+ expect(woke).toBe(false);
489
+ await new Promise((resolve) => {
490
+ setTimeout(resolve, 20);
491
+ });
492
+ }
493
+ }, 20_000);
494
+ });
@@ -81,7 +81,8 @@ function createMinimalMatchers() {
81
81
  }
82
82
  const TEST_USER_ID = '900000000000000777';
83
83
  const TEXT_CHANNEL_ID = '900000000000000778';
84
- describe('startup time measurement', () => {
84
+ const startupBenchmark = describe.skipIf(process.env['KIMAKI_STARTUP_BENCHMARK'] !== '1');
85
+ startupBenchmark('startup time measurement', () => {
85
86
  let directories;
86
87
  let discord;
87
88
  let botClient = null;
package/dist/store.js CHANGED
@@ -9,10 +9,13 @@ export const store = createStore(() => ({
9
9
  defaultVerbosity: 'text_and_essential_tools',
10
10
  defaultMentionMode: false,
11
11
  critiqueEnabled: true,
12
+ footerMentionsEnabled: true,
12
13
  enabledSkills: [],
13
14
  disabledSkills: [],
14
15
  allowedMentions: ['users'],
15
16
  allowAllUsers: false,
17
+ opencodeHostname: null,
18
+ opencodePort: null,
16
19
  restrictExternalDirectories: false,
17
20
  permissionTimeoutMs: 10 * 60 * 1000,
18
21
  useWorktrees: false,