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
package/dist/database.js CHANGED
@@ -2,6 +2,9 @@
2
2
  // Stores thread-session mappings, bot tokens, channel directories,
3
3
  // API keys, and model preferences in <dataDir>/discord-sessions.db.
4
4
  import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import * as errore from 'errore';
5
8
  import * as orm from 'drizzle-orm';
6
9
  import { getDb, closeDb } from './db.js';
7
10
  import { createLogger, LogPrefix } from './logger.js';
@@ -213,6 +216,104 @@ export async function setSessionStartSource({ sessionId, scheduleKind, scheduled
213
216
  set: { schedule_kind: scheduleKind, scheduled_task_id: scheduledTaskId ?? null, updated_at: new Date() },
214
217
  });
215
218
  }
219
+ export async function upsertSessionSleep({ sessionId, wakeAt, reason, }) {
220
+ const db = await getDb();
221
+ // A new occurrence gets a fresh delivery_id, which invalidates any in-flight
222
+ // wake for the previous occurrence: every delivery write is guarded on it.
223
+ const deliveryId = crypto.randomUUID();
224
+ await db.insert(schema.session_sleeps)
225
+ .values({
226
+ session_id: sessionId,
227
+ wake_at: wakeAt,
228
+ reason: reason ?? null,
229
+ status: 'planned',
230
+ delivery_id: deliveryId,
231
+ })
232
+ .onConflictDoUpdate({
233
+ target: schema.session_sleeps.session_id,
234
+ set: {
235
+ wake_at: wakeAt,
236
+ reason: reason ?? null,
237
+ status: 'planned',
238
+ delivery_id: deliveryId,
239
+ attempts: 0,
240
+ last_attempt_at: null,
241
+ created_at: new Date(),
242
+ },
243
+ });
244
+ return deliveryId;
245
+ }
246
+ export async function getSessionSleep({ sessionId }) {
247
+ const db = await getDb();
248
+ return await db.query.session_sleeps.findFirst({
249
+ where: { session_id: sessionId },
250
+ }) ?? null;
251
+ }
252
+ /** Rows that are due and not attempted too recently. Status stays `planned`. */
253
+ export async function getDueSessionSleeps({ now, retryAfterMs, limit, }) {
254
+ const db = await getDb();
255
+ const retryBefore = new Date(now.getTime() - retryAfterMs);
256
+ return db.select()
257
+ .from(schema.session_sleeps)
258
+ .where(orm.and(orm.eq(schema.session_sleeps.status, 'planned'), orm.lte(schema.session_sleeps.wake_at, now), orm.or(orm.isNull(schema.session_sleeps.last_attempt_at), orm.lte(schema.session_sleeps.last_attempt_at, retryBefore))))
259
+ .orderBy(orm.asc(schema.session_sleeps.wake_at))
260
+ .limit(limit);
261
+ }
262
+ /**
263
+ * Reserve one delivery attempt. The row never leaves `planned` here.
264
+ *
265
+ * Only `last_attempt_at` moves, which keeps concurrent ticks off the same row
266
+ * for retryAfterMs. Because posting does not change the status either, one
267
+ * retry path covers every loss: dying before the post, dying after it, or
268
+ * never receiving the gateway event for the message we posted.
269
+ */
270
+ export async function claimSessionSleepAttempt({ sessionId, deliveryId, now, retryAfterMs, }) {
271
+ const db = await getDb();
272
+ const retryBefore = new Date(now.getTime() - retryAfterMs);
273
+ const rows = await db.update(schema.session_sleeps)
274
+ .set({ last_attempt_at: now, attempts: orm.sql `${schema.session_sleeps.attempts} + 1` })
275
+ .where(orm.and(orm.eq(schema.session_sleeps.session_id, sessionId), orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned'), orm.lte(schema.session_sleeps.wake_at, now), orm.or(orm.isNull(schema.session_sleeps.last_attempt_at), orm.lte(schema.session_sleeps.last_attempt_at, retryBefore))))
276
+ .returning();
277
+ return rows[0] ?? null;
278
+ }
279
+ export async function markSessionSleepFailed({ sessionId, deliveryId, }) {
280
+ const db = await getDb();
281
+ const rows = await db.update(schema.session_sleeps)
282
+ .set({ status: 'failed' })
283
+ .where(orm.and(orm.eq(schema.session_sleeps.session_id, sessionId), orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned')))
284
+ .returning({ session_id: schema.session_sleeps.session_id });
285
+ return countRows(rows) > 0;
286
+ }
287
+ /**
288
+ * Ingress commit point. Marks the wake as delivered to the session.
289
+ *
290
+ * Returns false when the row is missing or no longer `planned`, which is how a
291
+ * wake that raced a user cancellation, or a duplicate of one already delivered,
292
+ * gets dropped instead of starting a turn.
293
+ */
294
+ export async function consumeSessionSleepWake({ deliveryId }) {
295
+ const db = await getDb();
296
+ const rows = await db.update(schema.session_sleeps)
297
+ .set({ status: 'consumed' })
298
+ .where(orm.and(orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned')))
299
+ .returning({ session_id: schema.session_sleeps.session_id });
300
+ return countRows(rows) > 0;
301
+ }
302
+ /**
303
+ * Cancel the pending sleep of the session currently bound to a thread.
304
+ *
305
+ * Runs on every ingress, so the binding is resolved with a correlated subquery
306
+ * rather than a second round trip. thread_id is the primary key of
307
+ * thread_sessions, so it yields at most one session.
308
+ */
309
+ export async function cancelSessionSleepForThread({ threadId }) {
310
+ const db = await getDb();
311
+ const rows = await db.update(schema.session_sleeps)
312
+ .set({ status: 'cancelled' })
313
+ .where(orm.and(orm.eq(schema.session_sleeps.status, 'planned'), orm.eq(schema.session_sleeps.session_id, orm.sql `(SELECT ${schema.thread_sessions.session_id} FROM ${schema.thread_sessions} WHERE ${schema.thread_sessions.thread_id} = ${threadId})`)))
314
+ .returning({ session_id: schema.session_sleeps.session_id });
315
+ return countRows(rows) > 0;
316
+ }
216
317
  export async function getSessionStartSourcesBySessionIds(sessionIds) {
217
318
  if (sessionIds.length === 0)
218
319
  return new Map();
@@ -226,6 +327,12 @@ export async function getSessionStartSourcesBySessionIds(sessionIds) {
226
327
  }
227
328
  return new Map(rows.map((row) => [row.session_id, row]));
228
329
  }
330
+ export async function getSessionStartSource({ sessionId }) {
331
+ const db = await getDb();
332
+ return await db.query.session_start_sources.findFirst({
333
+ where: { session_id: sessionId },
334
+ }) ?? null;
335
+ }
229
336
  export async function getChannelModel(channelId) {
230
337
  const db = await getDb();
231
338
  const row = await db.query.channel_models.findFirst({ where: { channel_id: channelId } });
@@ -440,9 +547,18 @@ export async function setThreadSession(threadId, sessionId) {
440
547
  }
441
548
  export async function upsertThreadSession({ threadId, sessionId, source }) {
442
549
  const db = await getDb();
550
+ // updated_at is written explicitly on BOTH paths, never left to the column's
551
+ // CURRENT_TIMESTAMP default. The default stores "YYYY-MM-DD HH:MM:SS" while
552
+ // Date values store ISO with a "T", and those two text formats do not sort
553
+ // against each other correctly. Writing ISO everywhere keeps the ordering in
554
+ // getThreadIdBySessionId honest and gives it millisecond resolution.
555
+ const boundAt = new Date();
443
556
  await db.insert(schema.thread_sessions)
444
- .values({ thread_id: threadId, session_id: sessionId, source })
445
- .onConflictDoUpdate({ target: schema.thread_sessions.thread_id, set: { session_id: sessionId, source } });
557
+ .values({ thread_id: threadId, session_id: sessionId, source, updated_at: boundAt })
558
+ .onConflictDoUpdate({
559
+ target: schema.thread_sessions.thread_id,
560
+ set: { session_id: sessionId, source, updated_at: boundAt },
561
+ });
446
562
  }
447
563
  export async function getThreadParentSessionId(threadId) {
448
564
  const db = await getDb();
@@ -462,9 +578,23 @@ export async function getThreadSessionSource(threadId) {
462
578
  const db = await getDb();
463
579
  return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId }, columns: { source: true } }))?.source;
464
580
  }
581
+ /**
582
+ * Reverse lookup of the Discord thread that currently owns an OpenCode session.
583
+ *
584
+ * `thread_sessions.session_id` is NOT unique: /resume binds an existing session
585
+ * to a brand new thread without clearing the old row. An unordered findFirst
586
+ * therefore returns an arbitrary (often dead) thread, which made plugin tools
587
+ * post into the wrong conversation. Order by the most recent binding instead,
588
+ * falling back to created_at for rows written before updated_at existed.
589
+ */
465
590
  export async function getThreadIdBySessionId(sessionId) {
466
591
  const db = await getDb();
467
- return (await db.query.thread_sessions.findFirst({ where: { session_id: sessionId } }))?.thread_id;
592
+ const rows = await db.select({ thread_id: schema.thread_sessions.thread_id })
593
+ .from(schema.thread_sessions)
594
+ .where(orm.eq(schema.thread_sessions.session_id, sessionId))
595
+ .orderBy(orm.desc(orm.sql `COALESCE(${schema.thread_sessions.updated_at}, ${schema.thread_sessions.created_at})`))
596
+ .limit(1);
597
+ return rows[0]?.thread_id;
468
598
  }
469
599
  export async function getAllThreadSessionIds() {
470
600
  const db = await getDb();
@@ -671,6 +801,25 @@ export async function findChannelsByDirectory({ directory, channelType }) {
671
801
  : undefined;
672
802
  return db.query.channel_directories.findMany({ where, columns: { channel_id: true, directory: true, channel_type: true, guild_id: true } });
673
803
  }
804
+ function canonicalizeDirectory(directory) {
805
+ const resolved = path.resolve(directory);
806
+ const real = errore.try(() => fs.realpathSync(resolved), (e) => new Error('realpath failed', { cause: e }));
807
+ if (real instanceof Error)
808
+ return resolved;
809
+ return real;
810
+ }
811
+ export async function findRegisteredTextChannelForDirectory(directory) {
812
+ const wanted = canonicalizeDirectory(directory);
813
+ const channels = await findChannelsByDirectory({ channelType: 'text' });
814
+ return channels.find((ch) => canonicalizeDirectory(ch.directory) === wanted) ?? null;
815
+ }
816
+ export function formatProjectAlreadyRegisteredError({ channelId, directory, }) {
817
+ return [
818
+ `Channel already exists for this directory: ${directory}`,
819
+ `Channel ID: ${channelId}`,
820
+ `Remove the mapping first: kimaki project remove ${channelId}`,
821
+ ].join('\n');
822
+ }
674
823
  export async function getAllTextChannelDirectories() {
675
824
  const db = await getDb();
676
825
  const rows = await db.query.channel_directories.findMany({ where: { channel_type: 'text' }, columns: { directory: true } });
package/dist/db.js CHANGED
@@ -89,6 +89,20 @@ async function initializeDb() {
89
89
  dbInstance = db;
90
90
  return db;
91
91
  }
92
+ // CREATE TABLE IF NOT EXISTS cannot drop columns. The first two session_sleeps
93
+ // shapes kept thread_id NOT NULL and posted_at. Inserts now omit thread_id, so
94
+ // existing DBs fail with SQLITE_CONSTRAINT_NOTNULL until those columns go away.
95
+ async function dropLegacySessionSleepsColumns(client) {
96
+ const info = await client.execute('PRAGMA table_info(session_sleeps)');
97
+ const columns = new Set(info.rows.map((row) => String(row.name)));
98
+ if (columns.has('thread_id')) {
99
+ await client.execute('ALTER TABLE session_sleeps DROP COLUMN thread_id');
100
+ }
101
+ if (columns.has('posted_at')) {
102
+ await client.execute('ALTER TABLE session_sleeps DROP COLUMN posted_at');
103
+ }
104
+ await client.execute("UPDATE session_sleeps SET status = 'planned' WHERE status = 'posted'");
105
+ }
92
106
  async function migrateSchema({ db, client, }) {
93
107
  const schemaPath = path.join(__dirname, '../src/schema.sql');
94
108
  const sql = fs.readFileSync(schemaPath, 'utf-8');
@@ -120,7 +134,12 @@ async function migrateSchema({ db, client, }) {
120
134
  "ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
121
135
  'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
122
136
  'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
137
+ 'ALTER TABLE thread_sessions ADD COLUMN updated_at DATETIME',
123
138
  'ALTER TABLE channel_directories ADD COLUMN guild_id TEXT',
139
+ // First session_sleeps shape had thread_id only. Later ticks query these.
140
+ 'ALTER TABLE session_sleeps ADD COLUMN delivery_id TEXT',
141
+ 'ALTER TABLE session_sleeps ADD COLUMN attempts INTEGER DEFAULT 0',
142
+ 'ALTER TABLE session_sleeps ADD COLUMN last_attempt_at DATETIME',
124
143
  ];
125
144
  for (const stmt of alterStatements) {
126
145
  await client.execute(stmt).catch(() => undefined);
@@ -140,10 +159,24 @@ async function migrateSchema({ db, client, }) {
140
159
  "UPDATE bot_tokens SET bot_mode = 'self_hosted' WHERE bot_mode = 'self-hosted'",
141
160
  "UPDATE bot_tokens SET proxy_url = REPLACE(proxy_url, 'discord-gateway.kimaki.xyz', 'discord-gateway.kimaki.dev') WHERE bot_mode = 'gateway' AND proxy_url LIKE '%discord-gateway.kimaki.xyz%'",
142
161
  "UPDATE thread_worktrees SET status = 'pending' WHERE status IS NULL",
162
+ "UPDATE session_sleeps SET delivery_id = lower(hex(randomblob(16))) WHERE delivery_id IS NULL",
163
+ 'UPDATE session_sleeps SET attempts = 0 WHERE attempts IS NULL',
143
164
  ];
144
165
  for (const stmt of migrationStatements) {
145
166
  await client.execute(stmt).catch(() => undefined);
146
167
  }
168
+ // Existing part_messages tables never get a rewritten FK. Drop orphans.
169
+ // NOT EXISTS, not NOT IN: a NULL thread_sessions.thread_id would make
170
+ // NOT IN unknown for every row and skip the whole cleanup.
171
+ await client.execute(`
172
+ DELETE FROM part_messages
173
+ WHERE NOT EXISTS (
174
+ SELECT 1
175
+ FROM thread_sessions
176
+ WHERE thread_sessions.thread_id = part_messages.thread_id
177
+ )
178
+ `);
179
+ await dropLegacySessionSleepsColumns(client);
147
180
  // Migrate legacy thread_worktrees rows into thread_workspaces.
148
181
  // Rows that already exist in thread_workspaces (by thread_id) are skipped.
149
182
  await client.execute(`
package/dist/db.test.js CHANGED
@@ -7,7 +7,8 @@ import { afterAll, describe, expect, test } from 'vitest';
7
7
  import { closeDb, getDb } from './db.js';
8
8
  import * as orm from 'drizzle-orm';
9
9
  import * as schema from './schema.js';
10
- import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getSessionEventSnapshot, getSessionModel, setSessionModel, } from './database.js';
10
+ import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getDueSessionSleeps, getSessionEventSnapshot, getSessionModel, getSessionSleep, setSessionModel, upsertSessionSleep, } from './database.js';
11
+ import { createClient } from '@libsql/client';
11
12
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
12
13
  import { chooseLockPort } from './test-utils.js';
13
14
  import { copyCurrentSessionModel } from './commands/model.js';
@@ -15,6 +16,271 @@ afterAll(async () => {
15
16
  await closeDb();
16
17
  });
17
18
  describe('getDb', () => {
19
+ test('schema.sql creates every drizzle table', () => {
20
+ const schemaTs = fs.readFileSync(path.join(import.meta.dirname, 'schema.ts'), 'utf8');
21
+ const schemaSql = fs.readFileSync(path.join(import.meta.dirname, 'schema.sql'), 'utf8');
22
+ const tablesFromTs = [
23
+ ...schemaTs.matchAll(/sqliteTable\('([^']+)'/g),
24
+ ].map((match) => match[1]);
25
+ const tablesFromSql = [
26
+ ...schemaSql.matchAll(/CREATE TABLE IF NOT EXISTS `([^`]+)`/g),
27
+ ].map((match) => match[1]);
28
+ expect(new Set(tablesFromSql)).toEqual(new Set(tablesFromTs));
29
+ expect(tablesFromSql).toContain('session_sleeps');
30
+ });
31
+ test('removes part_messages rows whose thread_sessions parent is gone', async () => {
32
+ await closeDb();
33
+ const previousDbUrl = process.env['KIMAKI_DB_URL'];
34
+ const dbPath = path.join(process.cwd(), `tmp/test-db-orphan-parts-${crypto.randomUUID().slice(0, 8)}.db`);
35
+ try {
36
+ const client = createClient({ url: `file:${dbPath}` });
37
+ await client.execute(`
38
+ CREATE TABLE thread_sessions (
39
+ thread_id text PRIMARY KEY,
40
+ session_id text NOT NULL
41
+ )
42
+ `);
43
+ await client.execute(`
44
+ CREATE TABLE part_messages (
45
+ part_id text PRIMARY KEY,
46
+ message_id text NOT NULL,
47
+ thread_id text NOT NULL,
48
+ created_at datetime DEFAULT CURRENT_TIMESTAMP,
49
+ CONSTRAINT fk_part_messages_thread_id_thread_sessions_thread_id_fk
50
+ FOREIGN KEY (thread_id) REFERENCES thread_sessions(thread_id) ON UPDATE CASCADE
51
+ )
52
+ `);
53
+ await client.execute(`
54
+ INSERT INTO thread_sessions (thread_id, session_id)
55
+ VALUES ('thr-keep', 'ses-keep'), ('thr-gone', 'ses-gone')
56
+ `);
57
+ await client.execute(`
58
+ INSERT INTO part_messages (part_id, message_id, thread_id)
59
+ VALUES
60
+ ('part-keep', 'msg-keep', 'thr-keep'),
61
+ ('part-orphan', 'msg-orphan', 'thr-gone')
62
+ `);
63
+ // sqlite3 CLI leaves foreign_keys OFF, which is how real installs
64
+ // accumulate these orphans. libsql defaults to ON.
65
+ await client.execute('PRAGMA foreign_keys = OFF');
66
+ await client.execute(`DELETE FROM thread_sessions WHERE thread_id = 'thr-gone'`);
67
+ await client.execute(`
68
+ INSERT INTO thread_sessions (thread_id, session_id)
69
+ VALUES (NULL, 'ses-null')
70
+ `);
71
+ await client.execute('PRAGMA foreign_keys = ON');
72
+ const before = await client.execute(`
73
+ SELECT COUNT(*) AS n FROM part_messages
74
+ WHERE NOT EXISTS (
75
+ SELECT 1 FROM thread_sessions
76
+ WHERE thread_sessions.thread_id = part_messages.thread_id
77
+ )
78
+ `);
79
+ expect(Number(before.rows[0]?.n)).toBe(1);
80
+ client.close();
81
+ process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
82
+ const db = await getDb();
83
+ const remaining = await db.query.part_messages.findMany({
84
+ columns: { part_id: true },
85
+ orderBy: { part_id: 'asc' },
86
+ });
87
+ expect(remaining).toMatchInlineSnapshot(`
88
+ [
89
+ {
90
+ "part_id": "part-keep",
91
+ },
92
+ ]
93
+ `);
94
+ }
95
+ finally {
96
+ await closeDb();
97
+ if (previousDbUrl === undefined) {
98
+ delete process.env['KIMAKI_DB_URL'];
99
+ }
100
+ else {
101
+ process.env['KIMAKI_DB_URL'] = previousDbUrl;
102
+ }
103
+ for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
104
+ try {
105
+ fs.unlinkSync(file);
106
+ }
107
+ catch {
108
+ // Test cleanup best effort.
109
+ }
110
+ }
111
+ }
112
+ });
113
+ test('deleting a thread_sessions row also deletes its part_messages', async () => {
114
+ const db = await getDb();
115
+ const threadId = `test-part-cascade-${crypto.randomUUID()}`;
116
+ await db.insert(schema.thread_sessions).values({
117
+ thread_id: threadId,
118
+ session_id: 'ses-part-cascade',
119
+ });
120
+ await db.insert(schema.part_messages).values({
121
+ part_id: `${threadId}-part`,
122
+ message_id: 'msg-part-cascade',
123
+ thread_id: threadId,
124
+ });
125
+ await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId));
126
+ const leftover = await db.query.part_messages.findFirst({
127
+ where: { thread_id: threadId },
128
+ });
129
+ expect(leftover).toBeUndefined();
130
+ });
131
+ test('adds session_sleeps delivery columns on databases created before that schema', async () => {
132
+ await closeDb();
133
+ const previousDbUrl = process.env['KIMAKI_DB_URL'];
134
+ const dbPath = path.join(process.cwd(), `tmp/test-db-legacy-sleeps-${crypto.randomUUID().slice(0, 8)}.db`);
135
+ try {
136
+ const client = createClient({ url: `file:${dbPath}` });
137
+ await client.execute(`
138
+ CREATE TABLE thread_sessions (
139
+ thread_id text PRIMARY KEY,
140
+ session_id text NOT NULL
141
+ )
142
+ `);
143
+ await client.execute(`
144
+ CREATE TABLE session_sleeps (
145
+ session_id text PRIMARY KEY,
146
+ thread_id text NOT NULL,
147
+ wake_at datetime NOT NULL,
148
+ reason text,
149
+ status text DEFAULT 'planned' NOT NULL,
150
+ created_at datetime DEFAULT CURRENT_TIMESTAMP
151
+ )
152
+ `);
153
+ await client.execute(`
154
+ INSERT INTO thread_sessions (thread_id, session_id)
155
+ VALUES ('thr-legacy', 'ses-legacy')
156
+ `);
157
+ await client.execute(`
158
+ INSERT INTO session_sleeps (session_id, thread_id, wake_at, status)
159
+ VALUES ('ses-legacy', 'thr-legacy', '2020-01-01T00:00:00.000Z', 'planned')
160
+ `);
161
+ client.close();
162
+ process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
163
+ await getDb();
164
+ const due = await getDueSessionSleeps({
165
+ now: new Date('2026-08-21T00:00:00Z'),
166
+ retryAfterMs: 30_000,
167
+ limit: 10,
168
+ });
169
+ const legacy = due.find((row) => row.session_id === 'ses-legacy');
170
+ expect(legacy?.delivery_id).toBeTruthy();
171
+ expect(legacy?.attempts).toBe(0);
172
+ // The first table required thread_id. After the column was dropped from
173
+ // the schema, inserts that omit it must still work on existing databases.
174
+ await upsertSessionSleep({
175
+ sessionId: 'ses-legacy-new',
176
+ wakeAt: new Date('2026-08-26T00:00:00Z'),
177
+ reason: 'check the reply',
178
+ });
179
+ const created = await getSessionSleep({ sessionId: 'ses-legacy-new' });
180
+ expect(created?.status).toBe('planned');
181
+ expect(created?.reason).toBe('check the reply');
182
+ expect(created?.delivery_id).toBeTruthy();
183
+ }
184
+ finally {
185
+ await closeDb();
186
+ if (previousDbUrl === undefined) {
187
+ delete process.env['KIMAKI_DB_URL'];
188
+ }
189
+ else {
190
+ process.env['KIMAKI_DB_URL'] = previousDbUrl;
191
+ }
192
+ for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
193
+ try {
194
+ fs.unlinkSync(file);
195
+ }
196
+ catch {
197
+ // Test cleanup best effort.
198
+ }
199
+ }
200
+ }
201
+ });
202
+ test('rebuilds session_sleeps that still have posted_at from the intermediate schema', async () => {
203
+ await closeDb();
204
+ const previousDbUrl = process.env['KIMAKI_DB_URL'];
205
+ const dbPath = path.join(process.cwd(), `tmp/test-db-posted-sleeps-${crypto.randomUUID().slice(0, 8)}.db`);
206
+ try {
207
+ const client = createClient({ url: `file:${dbPath}` });
208
+ await client.execute(`
209
+ CREATE TABLE thread_sessions (
210
+ thread_id text PRIMARY KEY,
211
+ session_id text NOT NULL
212
+ )
213
+ `);
214
+ await client.execute(`
215
+ CREATE TABLE session_sleeps (
216
+ session_id text PRIMARY KEY,
217
+ thread_id text NOT NULL,
218
+ wake_at datetime NOT NULL,
219
+ reason text,
220
+ status text DEFAULT 'planned' NOT NULL,
221
+ delivery_id text NOT NULL,
222
+ attempts integer DEFAULT 0 NOT NULL,
223
+ last_attempt_at datetime,
224
+ posted_at datetime,
225
+ created_at datetime DEFAULT CURRENT_TIMESTAMP
226
+ )
227
+ `);
228
+ await client.execute(`
229
+ INSERT INTO thread_sessions (thread_id, session_id)
230
+ VALUES ('thr-posted', 'ses-posted')
231
+ `);
232
+ await client.execute(`
233
+ INSERT INTO session_sleeps (
234
+ session_id, thread_id, wake_at, status, delivery_id, posted_at
235
+ )
236
+ VALUES (
237
+ 'ses-posted',
238
+ 'thr-posted',
239
+ '2020-01-01T00:00:00.000Z',
240
+ 'posted',
241
+ 'del-posted',
242
+ '2020-01-01T00:00:01.000Z'
243
+ )
244
+ `);
245
+ client.close();
246
+ process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
247
+ await getDb();
248
+ const due = await getDueSessionSleeps({
249
+ now: new Date('2026-08-21T00:00:00Z'),
250
+ retryAfterMs: 30_000,
251
+ limit: 10,
252
+ });
253
+ const posted = due.find((row) => row.session_id === 'ses-posted');
254
+ expect(posted?.status).toBe('planned');
255
+ expect(posted?.delivery_id).toBe('del-posted');
256
+ await upsertSessionSleep({
257
+ sessionId: 'ses-posted',
258
+ wakeAt: new Date('2026-08-26T00:00:00Z'),
259
+ reason: 'retry after schema change',
260
+ });
261
+ const updated = await getSessionSleep({ sessionId: 'ses-posted' });
262
+ expect(updated?.status).toBe('planned');
263
+ expect(updated?.reason).toBe('retry after schema change');
264
+ expect(updated?.delivery_id).not.toBe('del-posted');
265
+ }
266
+ finally {
267
+ await closeDb();
268
+ if (previousDbUrl === undefined) {
269
+ delete process.env['KIMAKI_DB_URL'];
270
+ }
271
+ else {
272
+ process.env['KIMAKI_DB_URL'] = previousDbUrl;
273
+ }
274
+ for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
275
+ try {
276
+ fs.unlinkSync(file);
277
+ }
278
+ catch {
279
+ // Test cleanup best effort.
280
+ }
281
+ }
282
+ }
283
+ });
18
284
  test('creates sqlite file and migrates schema automatically', async () => {
19
285
  const db = await getDb();
20
286
  const [session] = await db.insert(schema.thread_sessions)