kimaki 1.3.0 → 1.5.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 (346) hide show
  1. package/assets/fonts/Geist-Bold.ttf +0 -0
  2. package/assets/fonts/Geist-Regular.ttf +0 -0
  3. package/assets/fonts/GeistMono-Regular.ttf +0 -0
  4. package/assets/fonts/OFL.txt +92 -0
  5. package/dist/agent-ui.e2e.test.js +27 -13
  6. package/dist/agent-ui.e2e.test.js.map +1 -1
  7. package/dist/agent-ui.js +2 -1
  8. package/dist/agent-ui.js.map +1 -1
  9. package/dist/analytics.e2e.test.js +1 -1
  10. package/dist/analytics.e2e.test.js.map +1 -1
  11. package/dist/autostart.d.ts +4 -0
  12. package/dist/autostart.js +55 -10
  13. package/dist/autostart.js.map +1 -1
  14. package/dist/bot.d.ts +13 -6
  15. package/dist/bot.js +17 -15
  16. package/dist/bot.js.map +1 -1
  17. package/dist/btw.e2e.test.js +3 -3
  18. package/dist/builtin-skills.d.ts +3 -0
  19. package/dist/builtin-skills.js +29 -0
  20. package/dist/builtin-skills.js.map +1 -1
  21. package/dist/builtin-skills.test.js +72 -1
  22. package/dist/builtin-skills.test.js.map +1 -1
  23. package/dist/catalog-refresh.e2e.test.js +2 -2
  24. package/dist/cli/bot.d.ts +1 -1
  25. package/dist/cli/bot.js +33 -18
  26. package/dist/cli/bot.js.map +1 -1
  27. package/dist/cli/discord.js +32 -6
  28. package/dist/cli/discord.js.map +1 -1
  29. package/dist/cli/schedule.js +23 -9
  30. package/dist/cli/schedule.js.map +1 -1
  31. package/dist/cli/session.js +37 -12
  32. package/dist/cli/session.js.map +1 -1
  33. package/dist/cli/shared.d.ts +5 -3
  34. package/dist/cli/shared.js +45 -10
  35. package/dist/cli/shared.js.map +1 -1
  36. package/dist/cli-discord.e2e.test.js +13 -8
  37. package/dist/cli-discord.e2e.test.js.map +1 -1
  38. package/dist/cli-lock.e2e.test.js +87 -20
  39. package/dist/cli-lock.e2e.test.js.map +1 -1
  40. package/dist/cli-read.e2e.test.js +18 -9
  41. package/dist/cli-read.e2e.test.js.map +1 -1
  42. package/dist/cli-sessions.e2e.test.js +4 -3
  43. package/dist/cli-sessions.e2e.test.js.map +1 -1
  44. package/dist/cli.js +2 -0
  45. package/dist/cli.js.map +1 -1
  46. package/dist/commands/preference-commands.d.ts +4 -0
  47. package/dist/commands/preference-commands.js +38 -4
  48. package/dist/commands/preference-commands.js.map +1 -1
  49. package/dist/commands/session-commands.js +11 -8
  50. package/dist/commands/session-commands.js.map +1 -1
  51. package/dist/commands/thread-commands.js +94 -24
  52. package/dist/commands/thread-commands.js.map +1 -1
  53. package/dist/commands/worktree-commands.d.ts +5 -6
  54. package/dist/commands/worktree-commands.js +8 -7
  55. package/dist/commands/worktree-commands.js.map +1 -1
  56. package/dist/commands.e2e.test.js +25 -21
  57. package/dist/commands.e2e.test.js.map +1 -1
  58. package/dist/coordinator-agents.e2e.test.d.ts +1 -0
  59. package/dist/coordinator-agents.e2e.test.js +113 -0
  60. package/dist/coordinator-agents.e2e.test.js.map +1 -0
  61. package/dist/coordinator-idle.e2e.test.d.ts +1 -0
  62. package/dist/coordinator-idle.e2e.test.js +100 -0
  63. package/dist/coordinator-idle.e2e.test.js.map +1 -0
  64. package/dist/coordinator-queue.e2e.test.d.ts +1 -0
  65. package/dist/coordinator-queue.e2e.test.js +244 -0
  66. package/dist/coordinator-queue.e2e.test.js.map +1 -0
  67. package/dist/coordinator-warmup.e2e.test.d.ts +1 -0
  68. package/dist/coordinator-warmup.e2e.test.js +131 -0
  69. package/dist/coordinator-warmup.e2e.test.js.map +1 -0
  70. package/dist/coordinator.d.ts +6 -0
  71. package/dist/coordinator.e2e.test.d.ts +1 -0
  72. package/dist/coordinator.e2e.test.js +505 -0
  73. package/dist/coordinator.e2e.test.js.map +1 -0
  74. package/dist/coordinator.js +164 -0
  75. package/dist/coordinator.js.map +1 -0
  76. package/dist/db.d.ts +78 -3
  77. package/dist/db.test.js +1 -0
  78. package/dist/db.test.js.map +1 -1
  79. package/dist/diagram.d.ts +11 -0
  80. package/dist/diagram.js +232 -0
  81. package/dist/diagram.js.map +1 -0
  82. package/dist/diagram.test.d.ts +1 -0
  83. package/dist/diagram.test.js +52 -0
  84. package/dist/diagram.test.js.map +1 -0
  85. package/dist/duration.js +13 -5
  86. package/dist/duration.js.map +1 -1
  87. package/dist/effects.d.ts +6 -1
  88. package/dist/effects.js +97 -26
  89. package/dist/effects.js.map +1 -1
  90. package/dist/event-loop.d.ts +2 -3
  91. package/dist/event-loop.js +74 -44
  92. package/dist/event-loop.js.map +1 -1
  93. package/dist/format-parts.d.ts +21 -4
  94. package/dist/format-parts.js +40 -23
  95. package/dist/format-parts.js.map +1 -1
  96. package/dist/gateway-replay.e2e.test.js +4 -4
  97. package/dist/harness.e2e.test.js +2 -2
  98. package/dist/harness.e2e.test.js.map +1 -1
  99. package/dist/hello-thread.e2e.test.js +56 -5
  100. package/dist/hello-thread.e2e.test.js.map +1 -1
  101. package/dist/ingress.js +47 -12
  102. package/dist/ingress.js.map +1 -1
  103. package/dist/interaction-context.d.ts +4 -4
  104. package/dist/interaction-context.js +9 -7
  105. package/dist/interaction-context.js.map +1 -1
  106. package/dist/interrupt.e2e.test.js +8 -5
  107. package/dist/interrupt.e2e.test.js.map +1 -1
  108. package/dist/legacy.e2e.test.js +1 -1
  109. package/dist/lock-routes.d.ts +83 -31
  110. package/dist/lock-routes.js +68 -8
  111. package/dist/lock-routes.js.map +1 -1
  112. package/dist/lock-server.d.ts +3 -2
  113. package/dist/lock-server.js +23 -7
  114. package/dist/lock-server.js.map +1 -1
  115. package/dist/main.d.ts +6 -3
  116. package/dist/main.js +14 -4
  117. package/dist/main.js.map +1 -1
  118. package/dist/mainmast-idle.e2e.test.d.ts +1 -0
  119. package/dist/mainmast-idle.e2e.test.js +99 -0
  120. package/dist/mainmast-idle.e2e.test.js.map +1 -0
  121. package/dist/mainmast-queue.e2e.test.d.ts +1 -0
  122. package/dist/mainmast-queue.e2e.test.js +244 -0
  123. package/dist/mainmast-queue.e2e.test.js.map +1 -0
  124. package/dist/mainmast-warmup.e2e.test.d.ts +1 -0
  125. package/dist/mainmast-warmup.e2e.test.js +130 -0
  126. package/dist/mainmast-warmup.e2e.test.js.map +1 -0
  127. package/dist/mainmast.d.ts +6 -0
  128. package/dist/mainmast.e2e.test.d.ts +1 -0
  129. package/dist/mainmast.e2e.test.js +428 -0
  130. package/dist/mainmast.e2e.test.js.map +1 -0
  131. package/dist/mainmast.js +156 -0
  132. package/dist/mainmast.js.map +1 -0
  133. package/dist/markdown/markdown.test.js +14 -4
  134. package/dist/markdown/markdown.test.js.map +1 -1
  135. package/dist/markdown/render-markdown.js +9 -6
  136. package/dist/markdown/render-markdown.js.map +1 -1
  137. package/dist/onboarding-cli.e2e.test.js +7 -6
  138. package/dist/onboarding-cli.e2e.test.js.map +1 -1
  139. package/dist/onboarding.d.ts +3 -10
  140. package/dist/onboarding.e2e.test.js +8 -10
  141. package/dist/onboarding.e2e.test.js.map +1 -1
  142. package/dist/onboarding.js +14 -24
  143. package/dist/onboarding.js.map +1 -1
  144. package/dist/permissions.e2e.test.js +3 -3
  145. package/dist/plugin/coordinator.d.ts +4 -0
  146. package/dist/plugin/coordinator.js +389 -0
  147. package/dist/plugin/coordinator.js.map +1 -0
  148. package/dist/plugin/index.js +93 -61
  149. package/dist/plugin/index.js.map +1 -1
  150. package/dist/plugin/mainmast.d.ts +4 -0
  151. package/dist/plugin/mainmast.js +379 -0
  152. package/dist/plugin/mainmast.js.map +1 -0
  153. package/dist/plugin.e2e.test.js +1 -1
  154. package/dist/preference-commands.e2e.test.js +103 -24
  155. package/dist/preference-commands.e2e.test.js.map +1 -1
  156. package/dist/project.d.ts +1 -1
  157. package/dist/prompt.d.ts +22 -9
  158. package/dist/prompt.js +64 -48
  159. package/dist/prompt.js.map +1 -1
  160. package/dist/questions.e2e.test.js +13 -13
  161. package/dist/queue-sources.e2e.test.d.ts +1 -0
  162. package/dist/queue-sources.e2e.test.js +168 -0
  163. package/dist/queue-sources.e2e.test.js.map +1 -0
  164. package/dist/queue.d.ts +9 -1
  165. package/dist/queue.e2e.test.js +48 -30
  166. package/dist/queue.e2e.test.js.map +1 -1
  167. package/dist/queue.js +97 -24
  168. package/dist/queue.js.map +1 -1
  169. package/dist/queue.test.d.ts +1 -0
  170. package/dist/queue.test.js +57 -0
  171. package/dist/queue.test.js.map +1 -0
  172. package/dist/remote-send.e2e.test.js +6 -6
  173. package/dist/remote-send.js +2 -1
  174. package/dist/remote-send.js.map +1 -1
  175. package/dist/renderer.e2e.test.js +7 -7
  176. package/dist/restart.e2e.test.js +2 -2
  177. package/dist/routines.e2e.test.js +2 -2
  178. package/dist/scheduler.d.ts +3 -3
  179. package/dist/scheduler.e2e.test.js +43 -20
  180. package/dist/scheduler.e2e.test.js.map +1 -1
  181. package/dist/scheduler.js +4 -2
  182. package/dist/scheduler.js.map +1 -1
  183. package/dist/schema-sql.d.ts +1 -1
  184. package/dist/schema-sql.js +1 -1
  185. package/dist/schema-sql.js.map +1 -1
  186. package/dist/schema.d.ts +152 -6
  187. package/dist/schema.js +12 -0
  188. package/dist/schema.js.map +1 -1
  189. package/dist/session-commands.e2e.test.js +36 -11
  190. package/dist/session-commands.e2e.test.js.map +1 -1
  191. package/dist/session-events.d.ts +18 -6
  192. package/dist/session-events.js +175 -27
  193. package/dist/session-events.js.map +1 -1
  194. package/dist/session-ops.e2e.test.js +63 -12
  195. package/dist/session-ops.e2e.test.js.map +1 -1
  196. package/dist/sessions.d.ts +44 -21
  197. package/dist/sessions.js +177 -65
  198. package/dist/sessions.js.map +1 -1
  199. package/dist/shell.e2e.test.js +4 -4
  200. package/dist/slash-commands.js +6 -4
  201. package/dist/slash-commands.js.map +1 -1
  202. package/dist/slash-commands.test.js +2 -0
  203. package/dist/slash-commands.test.js.map +1 -1
  204. package/dist/sleep.e2e.test.js +29 -14
  205. package/dist/sleep.e2e.test.js.map +1 -1
  206. package/dist/sleeps.d.ts +2 -1
  207. package/dist/sleeps.js +21 -3
  208. package/dist/sleeps.js.map +1 -1
  209. package/dist/store.d.ts +1 -0
  210. package/dist/store.js +3 -2
  211. package/dist/store.js.map +1 -1
  212. package/dist/system-prompt.d.ts +5 -15
  213. package/dist/system-prompt.js +180 -131
  214. package/dist/system-prompt.js.map +1 -1
  215. package/dist/test/generate-log-plugin/index.d.ts +3 -0
  216. package/dist/test/generate-log-plugin/index.js +15 -0
  217. package/dist/test/generate-log-plugin/index.js.map +1 -0
  218. package/dist/test/harness.d.ts +20 -2
  219. package/dist/test/harness.js +51 -11
  220. package/dist/test/harness.js.map +1 -1
  221. package/dist/test/replay.js +11 -8
  222. package/dist/test/replay.js.map +1 -1
  223. package/dist/thread-reducer.d.ts +29 -6
  224. package/dist/thread-reducer.fixtures.test.js +58 -51
  225. package/dist/thread-reducer.fixtures.test.js.map +1 -1
  226. package/dist/thread-reducer.js +109 -50
  227. package/dist/thread-reducer.js.map +1 -1
  228. package/dist/thread-reducer.test.js +190 -48
  229. package/dist/thread-reducer.test.js.map +1 -1
  230. package/dist/tunnel.d.ts +9 -0
  231. package/dist/tunnel.js +117 -28
  232. package/dist/tunnel.js.map +1 -1
  233. package/dist/tunnel.test.js +81 -5
  234. package/dist/tunnel.test.js.map +1 -1
  235. package/dist/voice-attachments.e2e.test.js +108 -18
  236. package/dist/voice-attachments.e2e.test.js.map +1 -1
  237. package/dist/voice-call.e2e.test.js +9 -9
  238. package/dist/voice-call.js +6 -6
  239. package/dist/voice-call.js.map +1 -1
  240. package/dist/voice-openai.e2e.test.js +59 -23
  241. package/dist/voice-openai.e2e.test.js.map +1 -1
  242. package/dist/voice.d.ts +1 -1
  243. package/dist/voice.js +72 -24
  244. package/dist/voice.js.map +1 -1
  245. package/dist/working-directory.e2e.test.js +11 -11
  246. package/dist/working-directory.e2e.test.js.map +1 -1
  247. package/package.json +11 -5
  248. package/skills/kimaki-tunnel/SKILL.md +3 -2
  249. package/src/agent-ui.e2e.test.ts +27 -13
  250. package/src/agent-ui.ts +2 -1
  251. package/src/analytics.e2e.test.ts +1 -1
  252. package/src/autostart.ts +55 -10
  253. package/src/bot.ts +36 -19
  254. package/src/btw.e2e.test.ts +3 -3
  255. package/src/builtin-skills.test.ts +73 -1
  256. package/src/builtin-skills.ts +33 -0
  257. package/src/catalog-refresh.e2e.test.ts +2 -2
  258. package/src/cli/bot.ts +30 -19
  259. package/src/cli/discord.ts +30 -6
  260. package/src/cli/schedule.ts +23 -9
  261. package/src/cli/session.ts +35 -12
  262. package/src/cli/shared.ts +43 -8
  263. package/src/cli-discord.e2e.test.ts +13 -8
  264. package/src/cli-lock.e2e.test.ts +88 -20
  265. package/src/cli-read.e2e.test.ts +17 -8
  266. package/src/cli-sessions.e2e.test.ts +4 -3
  267. package/src/cli.ts +2 -0
  268. package/src/commands/preference-commands.ts +38 -4
  269. package/src/commands/session-commands.ts +12 -7
  270. package/src/commands/thread-commands.ts +92 -24
  271. package/src/commands/worktree-commands.ts +10 -9
  272. package/src/commands.e2e.test.ts +25 -21
  273. package/src/coordinator-agents.e2e.test.ts +145 -0
  274. package/src/coordinator-idle.e2e.test.ts +122 -0
  275. package/src/coordinator-queue.e2e.test.ts +279 -0
  276. package/src/coordinator-warmup.e2e.test.ts +158 -0
  277. package/src/coordinator.e2e.test.ts +545 -0
  278. package/src/coordinator.ts +168 -0
  279. package/src/db.test.ts +1 -0
  280. package/src/diagram.test.ts +56 -0
  281. package/src/diagram.ts +243 -0
  282. package/src/duration.ts +13 -5
  283. package/src/effects.ts +99 -27
  284. package/src/event-loop.ts +71 -45
  285. package/src/format-parts.ts +53 -22
  286. package/src/gateway-replay.e2e.test.ts +4 -4
  287. package/src/harness.e2e.test.ts +2 -2
  288. package/src/hello-thread.e2e.test.ts +59 -5
  289. package/src/ingress.ts +44 -11
  290. package/src/interaction-context.ts +12 -11
  291. package/src/interrupt.e2e.test.ts +8 -5
  292. package/src/legacy.e2e.test.ts +1 -1
  293. package/src/lock-routes.ts +62 -8
  294. package/src/lock-server.ts +23 -7
  295. package/src/main.ts +26 -10
  296. package/src/markdown/markdown.test.ts +15 -4
  297. package/src/markdown/render-markdown.ts +9 -6
  298. package/src/onboarding-cli.e2e.test.ts +7 -6
  299. package/src/onboarding.e2e.test.ts +8 -10
  300. package/src/onboarding.ts +13 -29
  301. package/src/permissions.e2e.test.ts +3 -3
  302. package/src/plugin/coordinator.ts +401 -0
  303. package/src/plugin/index.ts +86 -59
  304. package/src/plugin.e2e.test.ts +1 -1
  305. package/src/preference-commands.e2e.test.ts +103 -23
  306. package/src/prompt.ts +87 -43
  307. package/src/questions.e2e.test.ts +13 -13
  308. package/src/queue-sources.e2e.test.ts +197 -0
  309. package/src/queue.e2e.test.ts +48 -30
  310. package/src/queue.test.ts +59 -0
  311. package/src/queue.ts +104 -24
  312. package/src/remote-send.e2e.test.ts +6 -6
  313. package/src/remote-send.ts +2 -1
  314. package/src/renderer.e2e.test.ts +7 -7
  315. package/src/restart.e2e.test.ts +2 -2
  316. package/src/routines.e2e.test.ts +2 -2
  317. package/src/scheduler.e2e.test.ts +45 -19
  318. package/src/scheduler.ts +4 -2
  319. package/src/schema-sql.ts +1 -1
  320. package/src/schema.ts +13 -0
  321. package/src/session-commands.e2e.test.ts +38 -11
  322. package/src/session-events.ts +168 -25
  323. package/src/session-ops.e2e.test.ts +64 -11
  324. package/src/sessions.ts +217 -67
  325. package/src/shell.e2e.test.ts +4 -4
  326. package/src/slash-commands.test.ts +2 -0
  327. package/src/slash-commands.ts +6 -4
  328. package/src/sleep.e2e.test.ts +30 -13
  329. package/src/sleeps.ts +20 -3
  330. package/src/store.ts +5 -2
  331. package/src/system-prompt.ts +190 -134
  332. package/src/test/generate-log-plugin/index.ts +16 -0
  333. package/src/test/harness.ts +77 -17
  334. package/src/test/replay.ts +12 -7
  335. package/src/thread-reducer.fixtures.test.ts +58 -51
  336. package/src/thread-reducer.test.ts +199 -49
  337. package/src/thread-reducer.ts +135 -66
  338. package/src/tunnel.test.ts +81 -5
  339. package/src/tunnel.ts +124 -25
  340. package/src/voice-attachments.e2e.test.ts +110 -18
  341. package/src/voice-call.e2e.test.ts +9 -9
  342. package/src/voice-call.ts +6 -6
  343. package/src/voice-openai.e2e.test.ts +64 -24
  344. package/src/voice.ts +93 -26
  345. package/src/working-directory.e2e.test.ts +11 -11
  346. package/src/message-tool.e2e.test.ts +0 -45
package/src/queue.ts CHANGED
@@ -4,9 +4,14 @@
4
4
  //
5
5
  // inbox.enqueued (queue, while busy) ─▶ "Queued at position N" ack
6
6
  // inbox.enqueued (steer, echo) ─▶ the echo line (wakes, scheduled runs, TUI prompts)
7
- // inbox.delivered ─▶ "» user: text" echo, ack says "Queued message sent"
7
+ // inbox.delivered (or promoted) ─▶ "Queued message sent" reply to the user message, ack says "Queued"
8
8
  // inbox.cancelled ─▶ ack says "Removed from queue"
9
9
  //
10
+ // Every acked item gets that reply when it starts, in threads and coordinator
11
+ // channels alike. The prompt text is never posted again. Prompts without a Discord
12
+ // message (CLI, routines, TUI) have nothing for the ack to reply to: the ack
13
+ // shows the full prompt, and the "Queued message sent" reply goes to the ack.
14
+ //
10
15
  // A queued prompt's inbox ID is msg_discord_<messageId> (prompt.ts), so a
11
16
  // message delete or an edit maps to the item by ID alone. Items without a
12
17
  // Discord source message (/queue, CLI, commands) are removed with /clear-queue.
@@ -18,10 +23,10 @@ import type { Draft } from 'immer'
18
23
  import type { Bot } from './bot.ts'
19
24
  import { asSubtext, textOnly } from './format-parts.ts'
20
25
  import { stripTurnContext } from './system-prompt.ts'
26
+ import { markdownPayloads, type DiscordPayload } from './markdown/components.ts'
27
+ import { DISCORD_TEXT_LIMIT } from './markdown/render-markdown.ts'
21
28
  import type { Emit, ThreadView } from './thread-reducer.ts'
22
29
 
23
- const ECHO_LIMIT = 1_900
24
-
25
30
  // A root inbox user item not delivered yet, in inbox order.
26
31
  export type PendingInput = {
27
32
  inboxID: string
@@ -31,6 +36,10 @@ export type PendingInput = {
31
36
  username: string | null
32
37
  // Discord message the prompt came from (the ack replies to it).
33
38
  messageId: string | null
39
+ // The first answer text replies to this message. Only in a coordinator channel:
40
+ // a thread holds one conversation, so turns need no extra delimiter there.
41
+ // Not for acked items: their "Queued message sent" reply marks the start.
42
+ replyTo: string | null
34
43
  // An ack was posted; only acked items get an echo when they start.
35
44
  acked: boolean
36
45
  // Line from the prompt metadata (scheduled runs) or for prompts sent outside Discord; shown on delivery even without an ack.
@@ -45,36 +54,102 @@ function ackKey(inboxID: string): string {
45
54
  return `queue:${inboxID}`
46
55
  }
47
56
 
48
- function inputFields(payload: { text: string; metadata?: { readonly [key: string]: JsonValue } }) {
57
+ // CLI and scheduled prompts carry a UUID, not a Discord message to reply to.
58
+ export function discordMessageId(messageId: string): string | null {
59
+ return /^\d+$/.test(messageId) ? messageId : null
60
+ }
61
+
62
+ function inputFields({ payload, channelId }: { payload: { text: string; metadata?: { readonly [key: string]: JsonValue } }; channelId: string }) {
49
63
  const text = stripTurnContext(payload.text)
50
64
  const discord = payload.metadata?.['discord']
51
65
  // Sent outside Discord (TUI, other clients): echo it like V1 external sync did.
52
66
  if (!discord || typeof discord !== 'object' || Array.isArray(discord)) {
53
- return { external: true, text, username: null, messageId: null, echo: text.trim() ? formatEcho({ username: 'user', text }) : null }
67
+ return { external: true, text, username: null, messageId: null, replyTo: null, echo: text.trim() ? formatEcho({ username: 'user', text }) : null }
54
68
  }
55
69
  const username = discord['username']
56
- const messageId = discord['messageId']
70
+ const raw = discord['messageId']
57
71
  const echo = discord['echo']
72
+ const messageId = typeof raw === 'string' ? discordMessageId(raw) : null
58
73
  return {
59
74
  external: false,
60
75
  text,
61
76
  username: typeof username === 'string' ? username : null,
62
- // CLI and scheduled prompts carry a UUID, not a Discord message to reply to.
63
- messageId: typeof messageId === 'string' && /^\d+$/.test(messageId) ? messageId : null,
77
+ messageId,
78
+ // A coordinator prompt's conversation is the project channel itself.
79
+ replyTo: discord['threadId'] === channelId ? messageId : null,
64
80
  // Shown when a prompt without a Discord message is taken (wake, scheduled run).
65
81
  echo: typeof echo === 'string' ? echo : null,
66
82
  }
67
83
  }
68
84
 
69
- export function formatEcho({ username, text }: { username: string | null; text: string }): string {
70
- const body = text.length > ECHO_LIMIT ? `${text.slice(0, ECHO_LIMIT - 1)}…` : text
71
- return `» **${username ?? 'queued'}:** ${body}`
85
+ function echoLine({ username, text }: { username: string | null; text: string }): string {
86
+ // Block markdown only renders at the start of a line, so it goes below the prefix.
87
+ const separator = BLOCK_MARKDOWN_START.test(text) ? '\n' : ' '
88
+ return `» **${username ?? 'queued'}:**${separator}${text}`
89
+ }
90
+
91
+ // Headings, quotes, lists, code fences, rules, tables and subtext.
92
+ const BLOCK_MARKDOWN_START = /^ {0,3}(#{1,6}(\s|$)|-#\s|>|[-*+]\s|\d{1,9}[.)]\s|```|~~~|-{3,}\s*$|\|)/
93
+ const CODE_FENCE = /^ {0,3}(```|~~~)/
94
+
95
+ // Every prompt the bot posts (CLI, routines, TUI, voice, slash commands) is
96
+ // subtext, so it reads apart from model output. A long one becomes several messages (effects.ts).
97
+ export function formatEcho(input: { username: string | null; text: string }): string {
98
+ return subtext(echoLine(input))
99
+ }
100
+
101
+ // A long echo (formatEcho) as several messages, split like model markdown.
102
+ // Each piece gets its `-# ` again: a line cut at a word break would lose it.
103
+ export function echoPayloads(echo: string): DiscordPayload[] {
104
+ return markdownPayloads(echo).map((payload) => ('content' in payload ? { content: subtext(payload.content.trim()) } : payload))
105
+ }
106
+
107
+ // A slash command reply stands in for the user message: one Discord message, long text is cut.
108
+ export function formatEchoReply(input: { username: string | null; text: string }): string {
109
+ return cut(formatEcho(input), DISCORD_TEXT_LIMIT)
110
+ }
111
+
112
+ // Every non-empty line gets `-# `. Subtext only renders inline markdown: code
113
+ // blocks stay as they are, headings become bold, other block markers show as text.
114
+ function subtext(text: string): string {
115
+ let fenced = false
116
+ return text
117
+ .split('\n')
118
+ .flatMap((line) => {
119
+ if (CODE_FENCE.test(line)) fenced = !fenced
120
+ else if (!fenced && line.trim() === '') return []
121
+ else if (!fenced && !line.startsWith('-# ')) return [asSubtext(line.replace(HEADING, '**$1**'))]
122
+ return [line]
123
+ })
124
+ .join('\n')
125
+ }
126
+
127
+ const HEADING = /^ {0,3}#{1,6}\s+(.+?)\s*$/
128
+
129
+ function cut(text: string, limit: number): string {
130
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text
131
+ }
132
+
133
+ // The ack of a prompt with a Discord message replies to it. Without one (CLI,
134
+ // routines, TUI) the ack itself shows the prompt, and its status line below.
135
+ function ackContent(item: PendingInput, status: string | null): string {
136
+ if (item.messageId) return asSubtext(status ?? '')
137
+ const line = status ? `\n${asSubtext(status)}` : ''
138
+ return `${cut(subtext(item.echo ?? echoLine(item)), DISCORD_TEXT_LIMIT - line.length)}${line}`
72
139
  }
73
140
 
74
141
  type Slice = { draft: Draft<ThreadView>; emit: Emit }
75
142
 
76
- function closeAck({ emit }: Slice, item: PendingInput, content: string) {
77
- if (item.acked) emit({ type: 'edit', key: ackKey(item.inboxID), messages: [textOnly(asSubtext(content))] })
143
+ function closeAck({ emit }: Slice, item: PendingInput, status: string) {
144
+ if (item.acked) emit({ type: 'edit', key: ackKey(item.inboxID), messages: [textOnly(ackContent(item, status))] })
145
+ }
146
+
147
+ // An acked item starts running. The user message (or the ack without one)
148
+ // already shows the prompt: a reply to it marks where it runs, instead of a
149
+ // second copy. Also in a coordinator, where the answer may post no text (IDLE).
150
+ function markStarted({ emit }: Slice, item: PendingInput) {
151
+ const ack = textOnly(ackContent(item, item.messageId ? 'Queued' : null))
152
+ emit({ type: 'edit', key: ackKey(item.inboxID), messages: [ack], reply: asSubtext('Queued message sent'), replyTo: item.messageId })
78
153
  }
79
154
 
80
155
  // Removes the item from the inbox; returns it when it was still queued.
@@ -91,32 +166,34 @@ export function enqueueInput(
91
166
  const { draft, emit, data, busy } = slice
92
167
  const { item, inboxID } = data
93
168
  if (item.type !== 'user' || draft.inbox.some((candidate) => candidate.inboxID === inboxID)) return
94
- const { external, ...fields } = inputFields(item.payload)
169
+ const { external, ...fields } = inputFields({ payload: item.payload, channelId: draft.channelId })
95
170
  // The expanded prompt of a command Kimaki ran: the queue echo shows the typed command, never the template.
96
171
  const command = external ? draft.commandPending : null
97
172
  if (external) draft.commandPending = null
98
- const meta = command ? { ...fields, ...command, echo: null } : fields
173
+ const meta = command ? { ...fields, ...command } : fields
99
174
  // Idle with nothing pending: OpenCode runs it at once, like a normal message.
100
175
  const acked = item.delivery === 'queue' && (busy || draft.inbox.length > 0)
101
- draft.inbox.push({ inboxID, delivery: item.delivery, ...meta, acked })
176
+ const pending: PendingInput = { inboxID, delivery: item.delivery, ...meta, acked }
177
+ draft.inbox.push(pending)
102
178
  if (item.delivery !== 'queue') {
103
179
  if (!meta.echo) return
104
- emit({ type: 'send', text: meta.echo })
180
+ emit({ type: 'send', text: meta.echo, echo: true })
105
181
  draft.lastKind = null
106
182
  return
107
183
  }
108
184
  if (!acked) return
109
185
  const position = queuedItems(draft).length
110
- const ack = textOnly(asSubtext(`Queued at position ${position}. Delete the original message to remove it, or use /clear-queue position:${position}`))
111
- emit({ type: 'show', key: ackKey(inboxID), messages: [ack], replyTo: meta.messageId, notify: false })
186
+ const remove = meta.messageId ? 'Delete the original message to remove it, or use' : 'Remove it with'
187
+ const ack = textOnly(ackContent(pending, `Queued at position ${position}. ${remove} /clear-queue position:${position}`))
188
+ emit({ type: 'show', key: ackKey(inboxID), messages: [ack], replyTo: meta.messageId, notify: false, echo: true })
112
189
  }
113
190
 
114
191
  export function deliverQueued(slice: Slice & { inboxID: string }) {
115
192
  const item = takeQueued(slice, slice.inboxID)
116
193
  if (!item || (!item.acked && !item.echo)) return
117
- closeAck(slice, item, 'Queued message sent')
118
- slice.emit({ type: 'send', text: item.echo ?? formatEcho(item) })
119
194
  slice.draft.lastKind = null
195
+ if (item.acked) return markStarted(slice, item)
196
+ slice.emit({ type: 'send', text: item.echo ?? formatEcho(item), echo: true })
120
197
  }
121
198
 
122
199
  export function cancelQueued(slice: Slice & { inboxID: string }) {
@@ -128,7 +205,7 @@ export function cancelQueued(slice: Slice & { inboxID: string }) {
128
205
  export function promoteQueued(slice: Slice & { inboxID: string }) {
129
206
  const item = slice.draft.inbox.find((candidate) => candidate.inboxID === slice.inboxID)
130
207
  if (item?.delivery !== 'queue') return
131
- closeAck(slice, { ...item }, 'Queued message sent')
208
+ if (item.acked) markStarted(slice, { ...item })
132
209
  item.delivery = 'steer'
133
210
  }
134
211
 
@@ -146,8 +223,11 @@ export function hydrateQueue(slice: Slice & { inbox: readonly SessionInboxInfo[]
146
223
  draft.inbox = users.map((item): PendingInput => {
147
224
  const existing = known.get(item.id)
148
225
  if (existing?.delivery === item.delivery) return existing
149
- if (existing?.delivery === 'queue' && item.delivery === 'steer') closeAck(slice, existing, 'Queued message sent')
150
- const { external: _external, ...fields } = inputFields(item.payload)
226
+ if (existing?.delivery === 'queue' && item.delivery === 'steer') {
227
+ if (existing.acked) markStarted(slice, existing)
228
+ return { ...existing, delivery: 'steer' }
229
+ }
230
+ const { external: _external, ...fields } = inputFields({ payload: item.payload, channelId: draft.channelId })
151
231
  return { inboxID: item.id, delivery: item.delivery, ...fields, acked: item.delivery === 'queue' }
152
232
  })
153
233
  }
@@ -38,17 +38,17 @@ test('CLI remote envelope runs only on the owning bot and returns its IDs', asyn
38
38
  await waitForFooter({ discord: twin.discord, threadId: ids.threadId, count: 2 })
39
39
  expect((await twin.discord.thread(ids.threadId).text()).replaceAll(ids.threadId, 'THREAD')).toMatchInlineSnapshot(`
40
40
  "--- from: assistant (TestBot)
41
- » **CLI:** Remote first
42
- Files: remote.txt
41
+ -# » **CLI:** Remote first
42
+ -# Files: remote.txt
43
43
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
44
44
  ok
45
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*
45
+ -# *main ⋅ Ns ⋅ deterministic-v2*
46
46
  Remote queued. queue
47
47
  [embed]
48
48
  Delivered to <#THREAD>
49
49
  [embed]
50
50
  ok
51
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
51
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
52
52
  `)
53
53
  expect(a.store.getState().roots[ids.threadId]).toBeUndefined()
54
54
  expect(b.store.getState().roots[ids.threadId]).toBe(ids.sessionId)
@@ -67,10 +67,10 @@ test('CI send with only KIMAKI_BOT_TOKEN and no local bot reaches the owning bot
67
67
  await waitForFooter({ discord: twin.discord, threadId: ids.threadId })
68
68
  expect(await twin.discord.thread(ids.threadId).text()).toMatchInlineSnapshot(`
69
69
  "--- from: assistant (TestBot)
70
- » **CLI:** From CI
70
+ -# » **CLI:** From CI
71
71
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
72
72
  ok
73
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
73
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
74
74
  `)
75
75
  expect(b.store.getState().roots[ids.threadId]).toBe(ids.sessionId)
76
76
  expect(fs.readdirSync(ciDataDir)).toEqual([])
@@ -36,7 +36,8 @@ export function promptFilePath(file: { uri: string }): string {
36
36
  // file path; the prompt file goes first, because the receiver reads the first attachment.
37
37
  export function remoteEnvelope(input: SendInput) {
38
38
  const requestId = crypto.randomBytes(8).toString('hex')
39
- const { files, prompt, ...options } = input
39
+ // A session of this machine means nothing to the receiving bot.
40
+ const { files, prompt, senderSessionId: _sender, ...options } = input
40
41
  // A prompt over the message limit travels as an attachment; the content keeps a preview.
41
42
  const long = prompt.length > 2000
42
43
  const footer = `${REMOTE_SEND_PREFIX}${JSON.stringify({ requestId, options, ...(long && { promptFile: REMOTE_PROMPT_FILE }) })}`
@@ -166,7 +166,7 @@ test('shell and edit lines are shown, read is hidden at the default verbosity',
166
166
  -# ◼︎ edit *notes.md* (+1-0)
167
167
 
168
168
  Edited notes.md.
169
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
169
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
170
170
  `)
171
171
  })
172
172
 
@@ -182,7 +182,7 @@ test('Code Mode execute shows its description, then one line per inner tool call
182
182
  -# ┣ execute.opencode.list\\_mcp\\_resources
183
183
 
184
184
  Found the models.
185
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
185
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
186
186
  `)
187
187
  })
188
188
 
@@ -199,7 +199,7 @@ test('Code Mode execute shows failed inner calls and the code error', async () =
199
199
  -# ⨯ execute _Error: broken on purpose_
200
200
 
201
201
  The code failed.
202
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
202
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
203
203
  `)
204
204
  })
205
205
 
@@ -252,7 +252,7 @@ test('table and callout render as components, long code splits into valid messag
252
252
  {
253
253
  "components": false,
254
254
  "fences": 0,
255
- "length": 49,
255
+ "length": 39,
256
256
  },
257
257
  ]
258
258
  `)
@@ -356,7 +356,7 @@ test('table and callout render as components, long code splits into valid messag
356
356
  const value78 = compute(78) // line 78
357
357
  const value79 = compute(79) // line 79
358
358
  \`\`\`
359
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
359
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
360
360
  `)
361
361
  })
362
362
 
@@ -371,7 +371,7 @@ test('foreground subagent: child tool lines labelled with the agent, one footer'
371
371
  -# ┣ general ⋅ shell _ls \\*.md_
372
372
 
373
373
  The child found notes.md.
374
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
374
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
375
375
  `)
376
376
  })
377
377
 
@@ -385,6 +385,6 @@ test('channel verbosity text shows only text and edit lines', async () => {
385
385
  -# ◼︎ edit *notes.md* (+1-0)
386
386
 
387
387
  Edited notes.md.
388
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
388
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
389
389
  `)
390
390
  })
@@ -62,12 +62,12 @@ test('restarted bot continues the session of an existing thread', async () => {
62
62
  --- from: assistant (TestBot)
63
63
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
64
64
  ok
65
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*
65
+ -# *main ⋅ Ns ⋅ deterministic-v2*
66
66
  --- from: user (tommy)
67
67
  second message after restart
68
68
  --- from: assistant (TestBot)
69
69
  ok
70
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
70
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
71
71
  `)
72
72
  const rows = await bot.db.query.thread_sessions.findMany()
73
73
  expect(rows.map((row) => row.session_id)).toEqual([binding?.session_id])
@@ -92,10 +92,10 @@ test('a routine row written by V1 runs unchanged', async () => {
92
92
  await waitForFooter({ discord: twin.discord, threadId: threadId! })
93
93
  expect(await twin.discord.thread(threadId!).text()).toMatchInlineSnapshot(`
94
94
  "--- from: assistant (TestBot)
95
- » **routine #3:** V1 weekly check
95
+ -# » **routine #3:** V1 weekly check
96
96
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
97
97
  ok
98
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
98
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
99
99
  `)
100
100
  expect((await listTasks()).find((task) => task.id === row!.id)).toMatchObject({ status: 'planned', nextRunAt: '2030-01-14T09:00:00.000Z' })
101
101
  // V1 history: finished rows only show with the V1 `task list --all`.
@@ -12,10 +12,13 @@ import {
12
12
  manualClock,
13
13
  schedulingKit,
14
14
  seedProjectChannel,
15
+ slowTextMatcher,
15
16
  startOpencodeTestServer,
16
17
  startTestBot,
17
18
  startTwin,
18
19
  tempDataDir,
20
+ textParts,
21
+ waitForBotMessageContaining,
19
22
  waitForFooter,
20
23
  warmUp,
21
24
  type OpencodeTestServer,
@@ -30,7 +33,10 @@ let bot: BotHandle
30
33
  const { cli, schedule, tick, listTasks, request } = schedulingKit({ suite: () => ({ bot, twin, server }), dataDir, clock })
31
34
 
32
35
  beforeAll(async () => {
33
- ;[server, twin] = await Promise.all([startOpencodeTestServer(), startTwin()])
36
+ ;[server, twin] = await Promise.all([startOpencodeTestServer({ matchers: [
37
+ slowTextMatcher({ marker: 'slow-marker', text: 'slow-done', delayMs: 800 }),
38
+ { id: 'report', priority: 50, when: { latestUserTextIncludes: 'Report when done' }, then: { parts: textParts('report ok') } },
39
+ ] }), startTwin()])
34
40
  await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory })
35
41
  bot = await startTestBot({ dataDir, twin, server, clock, schedulerIntervalMs: null })
36
42
  await warmUp({ server })
@@ -51,10 +57,10 @@ test('a one-shot routine starts a thread at its time, then is gone', async () =>
51
57
  await waitForFooter({ discord: twin.discord, threadId: threadId! })
52
58
  expect(await twin.discord.thread(threadId!).text()).toMatchInlineSnapshot(`
53
59
  "--- from: assistant (TestBot)
54
- » **routine #1:** Check the deploy once
60
+ -# » **routine #1:** Check the deploy once
55
61
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
56
62
  ok
57
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
63
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
58
64
  `)
59
65
  const thread = (await twin.discord.channel(twin.channelId).getThreads()).find((candidate) => candidate.id === threadId)
60
66
  expect(thread?.name).toBe('Deploy check')
@@ -93,10 +99,10 @@ test('a cron routine fires once per occurrence, in a new thread each time', asyn
93
99
  expect([first.length, second.length]).toEqual([1, 1])
94
100
  expect(await twin.discord.thread(second[0]!).text()).toMatchInlineSnapshot(`
95
101
  "--- from: assistant (TestBot)
96
- » **routine #2:** Hourly report
102
+ -# » **routine #2:** Hourly report
97
103
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
98
104
  ok
99
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
105
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
100
106
  `)
101
107
  expect((await listTasks()).find((row) => row.id === task.routineId)).toMatchObject({ status: 'planned', nextRunAt: '2030-01-01T13:00:00.000Z' })
102
108
  await request('task.delete', { id: task.routineId })
@@ -114,16 +120,16 @@ test('a routine for an existing thread prompts that thread, with or without . qu
114
120
  await waitForFooter({ discord: twin.discord, threadId: started.threadId, count: 3 })
115
121
  expect(await twin.discord.thread(started.threadId).text()).toMatchInlineSnapshot(`
116
122
  "--- from: assistant (TestBot)
117
- » **CLI:** Thread for a reminder
123
+ -# » **CLI:** Thread for a reminder
118
124
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
119
125
  ok
120
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*
121
- » **routine #3:** Reminder: check the logs
126
+ -# *main ⋅ Ns ⋅ deterministic-v2*
127
+ -# » **routine #3:** Reminder: check the logs
122
128
  ok
123
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*
124
- » **routine #4:** Then summarize them
129
+ -# *main ⋅ Ns ⋅ deterministic-v2*
130
+ -# » **routine #4:** Then summarize them
125
131
  ok
126
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
132
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
127
133
  `)
128
134
  })
129
135
 
@@ -136,14 +142,12 @@ test('--pre-run: a failing command skips the run, output is appended to the prom
136
142
  await waitForFooter({ discord: twin.discord, threadId: created[0]! })
137
143
  expect(await twin.discord.thread(created[0]!).text()).toMatchInlineSnapshot(`
138
144
  "--- from: assistant (TestBot)
139
- » **routine #6:** Summarize the new issues
140
-
141
- ## Pre-run command output
142
-
143
- issue 42 is new
145
+ -# » **routine #6:** Summarize the new issues
146
+ -# **Pre-run command output**
147
+ -# issue 42 is new
144
148
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
145
149
  ok
146
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
150
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
147
151
  `)
148
152
  // A skipped one-shot is done too, like V1.
149
153
  expect((await listTasks()).filter((row) => row.id === skipped.routineId || row.id === ran.routineId)).toEqual([])
@@ -162,14 +166,14 @@ test('recurring worktree routines get a fresh checkout on every run', async () =
162
166
  const normalize = (text: string) => text.replace(/report-[a-f0-9]{8}/g, 'report-ID')
163
167
  expect(normalize(await twin.discord.thread(first[0]!).text())).toMatchInlineSnapshot(`
164
168
  "--- from: assistant (TestBot)
165
- » **routine #7:** Isolated recurring report
169
+ -# » **routine #7:** Isolated recurring report
166
170
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
167
171
  ok
168
172
  -# *report-ID ⋅ opencode/kimaki-report-ID ⋅ Ns ⋅ deterministic-v2*"
169
173
  `)
170
174
  expect(normalize(await twin.discord.thread(second[0]!).text())).toMatchInlineSnapshot(`
171
175
  "--- from: assistant (TestBot)
172
- » **routine #7:** Isolated recurring report
176
+ -# » **routine #7:** Isolated recurring report
173
177
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
174
178
  ok
175
179
  -# *report-ID ⋅ opencode/kimaki-report-ID ⋅ Ns ⋅ deterministic-v2*"
@@ -180,3 +184,25 @@ test('recurring worktree routines get a fresh checkout on every run', async () =
180
184
  expect(directories.every((directory) => directory !== server.projectDirectory)).toBe(true)
181
185
  await request('task.delete', { id: task.routineId })
182
186
  })
187
+
188
+ // Without --allow-concurrency a thread routine waits until the thread is idle, then runs at once.
189
+ test('a . queue routine with --allow-concurrency that fires while its thread is busy waits in the queue, then gets its reply', async () => {
190
+ const started = await send(bot, { channelId: twin.channelId, prompt: 'Long work slow-marker' })
191
+ if (started instanceof Error) throw started
192
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: started.threadId, text: '*using ' })
193
+ await request('send', { threadId: started.threadId, prompt: 'Report when done. queue', sendAt: '2030-01-01T19:00:00Z', allowConcurrency: true })
194
+ expect(await tick('2030-01-01T19:00:00Z')).toEqual([])
195
+ await waitForFooter({ discord: twin.discord, threadId: started.threadId, count: 2 })
196
+ expect(await twin.discord.thread(started.threadId).text({ showReplies: true })).toMatchInlineSnapshot(`
197
+ "--- from: assistant (TestBot)
198
+ -# » **CLI:** Long work slow-marker
199
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
200
+ -# » **routine #8:** Report when done
201
+ slow-done
202
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
203
+ [reply to TestBot: -# » **routine #8:** Report when done]
204
+ -# Queued message sent
205
+ report ok
206
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
207
+ `)
208
+ })
package/src/scheduler.ts CHANGED
@@ -423,8 +423,10 @@ export async function createTask(bot: Bot, { send: input, options }: { send: Sen
423
423
  if (threadId) {
424
424
  const thread = await bot.discord.channels.fetch(threadId).catch((cause) => new DiscordError({ operation: `fetch thread ${threadId}`, cause }))
425
425
  if (thread instanceof Error) return thread
426
- if (!thread?.isThread() || !thread.parentId) return null
427
- return projectOf(bot, thread.parentId)
426
+ // A coordinator channel is its own project channel.
427
+ const channelId = thread?.isThread() ? thread.parentId : thread?.id
428
+ if (!channelId) return null
429
+ return projectOf(bot, channelId)
428
430
  }
429
431
  return bot.db.query.channel_directories
430
432
  .findFirst({ where: input.channelId ? { channel_id: input.channelId } : { directory: path.resolve(input.project!) } })
package/src/schema-sql.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by pnpm generate from src/schema.ts. Do not edit.
2
2
 
3
- export const SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS `bot_api_keys` (\n\t`app_id` text PRIMARY KEY,\n\t`gemini_api_key` text,\n\t`openai_api_key` text,\n\t`xai_api_key` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_bot_api_keys_app_id_bot_tokens_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `bot_tokens`(`app_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `bot_settings` (\n\t`app_id` text PRIMARY KEY,\n\t`voice_call_voice` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_bot_settings_app_id_bot_tokens_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `bot_tokens`(`app_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `bot_tokens` (\n\t`app_id` text PRIMARY KEY,\n\t`token` text NOT NULL,\n\t`bot_mode` text DEFAULT 'self_hosted' NOT NULL,\n\t`client_id` text,\n\t`client_secret` text,\n\t`proxy_url` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`last_used_at` datetime\n);\n\nCREATE TABLE IF NOT EXISTS `channel_agents` (\n\t`channel_id` text PRIMARY KEY,\n\t`agent_name` text NOT NULL,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_channel_agents_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `channel_directories` (\n\t`channel_id` text PRIMARY KEY,\n\t`directory` text NOT NULL,\n\t`channel_type` text NOT NULL,\n\t`guild_id` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE IF NOT EXISTS `channel_mention_mode` (\n\t`channel_id` text PRIMARY KEY,\n\t`enabled` integer DEFAULT 0 NOT NULL,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_channel_mention_mode_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `channel_models` (\n\t`channel_id` text PRIMARY KEY,\n\t`model_id` text NOT NULL,\n\t`variant` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_channel_models_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `channel_verbosity` (\n\t`channel_id` text PRIMARY KEY,\n\t`verbosity` text DEFAULT 'tools_and_text' NOT NULL,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_channel_verbosity_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `channel_worktrees` (\n\t`channel_id` text PRIMARY KEY,\n\t`enabled` integer DEFAULT 0 NOT NULL,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_channel_worktrees_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `global_models` (\n\t`app_id` text PRIMARY KEY,\n\t`model_id` text NOT NULL,\n\t`variant` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_global_models_app_id_bot_tokens_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `bot_tokens`(`app_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `guild_categories` (\n\t`guild_id` text PRIMARY KEY,\n\t`category_id` text,\n\t`audio_category_id` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE IF NOT EXISTS `scheduled_tasks` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT,\n\t`status` text DEFAULT 'planned' NOT NULL,\n\t`schedule_kind` text NOT NULL,\n\t`run_at` datetime,\n\t`cron_expr` text,\n\t`timezone` text,\n\t`next_run_at` datetime NOT NULL,\n\t`running_started_at` datetime,\n\t`last_run_at` datetime,\n\t`last_error` text,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`payload_json` text NOT NULL,\n\t`prompt_preview` text NOT NULL,\n\t`channel_id` text,\n\t`thread_id` text,\n\t`session_id` text,\n\t`project_directory` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_scheduled_tasks_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE ON DELETE SET NULL,\n\tCONSTRAINT `fk_scheduled_tasks_thread_id_thread_sessions_thread_id_fk` FOREIGN KEY (`thread_id`) REFERENCES `thread_sessions`(`thread_id`) ON UPDATE CASCADE ON DELETE SET NULL\n);\n\nCREATE TABLE IF NOT EXISTS `session_sleeps` (\n\t`session_id` text PRIMARY KEY,\n\t`wake_at` datetime NOT NULL,\n\t`reason` text,\n\t`status` text DEFAULT 'planned' NOT NULL,\n\t`delivery_id` text NOT NULL,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`last_attempt_at` datetime,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE IF NOT EXISTS `thread_sessions` (\n\t`thread_id` text PRIMARY KEY,\n\t`session_id` text NOT NULL,\n\t`source` text DEFAULT 'kimaki' NOT NULL,\n\t`last_synced_name` text,\n\t`parent_session_id` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS `scheduled_tasks_status_next_run_at_idx` ON `scheduled_tasks` (`status`,`next_run_at`);\nCREATE INDEX IF NOT EXISTS `scheduled_tasks_channel_id_status_idx` ON `scheduled_tasks` (`channel_id`,`status`);\nCREATE INDEX IF NOT EXISTS `scheduled_tasks_thread_id_status_idx` ON `scheduled_tasks` (`thread_id`,`status`);\nCREATE INDEX IF NOT EXISTS `session_sleeps_status_wake_at_idx` ON `session_sleeps` (`status`,`wake_at`);"
3
+ export const SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS `bot_api_keys` (\n\t`app_id` text PRIMARY KEY,\n\t`gemini_api_key` text,\n\t`openai_api_key` text,\n\t`xai_api_key` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_bot_api_keys_app_id_bot_tokens_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `bot_tokens`(`app_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `bot_settings` (\n\t`app_id` text PRIMARY KEY,\n\t`voice_call_voice` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_bot_settings_app_id_bot_tokens_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `bot_tokens`(`app_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `bot_tokens` (\n\t`app_id` text PRIMARY KEY,\n\t`token` text NOT NULL,\n\t`bot_mode` text DEFAULT 'self_hosted' NOT NULL,\n\t`client_id` text,\n\t`client_secret` text,\n\t`proxy_url` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`last_used_at` datetime\n);\n\nCREATE TABLE IF NOT EXISTS `channel_agents` (\n\t`channel_id` text PRIMARY KEY,\n\t`agent_name` text NOT NULL,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_channel_agents_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `channel_coordinator` (\n\t`channel_id` text PRIMARY KEY,\n\t`enabled` integer NOT NULL,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_channel_coordinator_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `channel_directories` (\n\t`channel_id` text PRIMARY KEY,\n\t`directory` text NOT NULL,\n\t`channel_type` text NOT NULL,\n\t`guild_id` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE IF NOT EXISTS `channel_mention_mode` (\n\t`channel_id` text PRIMARY KEY,\n\t`enabled` integer DEFAULT 0 NOT NULL,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_channel_mention_mode_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `channel_models` (\n\t`channel_id` text PRIMARY KEY,\n\t`model_id` text NOT NULL,\n\t`variant` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_channel_models_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `channel_verbosity` (\n\t`channel_id` text PRIMARY KEY,\n\t`verbosity` text DEFAULT 'tools_and_text' NOT NULL,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_channel_verbosity_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `channel_worktrees` (\n\t`channel_id` text PRIMARY KEY,\n\t`enabled` integer DEFAULT 0 NOT NULL,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_channel_worktrees_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `global_models` (\n\t`app_id` text PRIMARY KEY,\n\t`model_id` text NOT NULL,\n\t`variant` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_global_models_app_id_bot_tokens_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `bot_tokens`(`app_id`) ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS `guild_categories` (\n\t`guild_id` text PRIMARY KEY,\n\t`category_id` text,\n\t`audio_category_id` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE IF NOT EXISTS `scheduled_tasks` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT,\n\t`status` text DEFAULT 'planned' NOT NULL,\n\t`schedule_kind` text NOT NULL,\n\t`run_at` datetime,\n\t`cron_expr` text,\n\t`timezone` text,\n\t`next_run_at` datetime NOT NULL,\n\t`running_started_at` datetime,\n\t`last_run_at` datetime,\n\t`last_error` text,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`payload_json` text NOT NULL,\n\t`prompt_preview` text NOT NULL,\n\t`channel_id` text,\n\t`thread_id` text,\n\t`session_id` text,\n\t`project_directory` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\tCONSTRAINT `fk_scheduled_tasks_channel_id_channel_directories_channel_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `channel_directories`(`channel_id`) ON UPDATE CASCADE ON DELETE SET NULL,\n\tCONSTRAINT `fk_scheduled_tasks_thread_id_thread_sessions_thread_id_fk` FOREIGN KEY (`thread_id`) REFERENCES `thread_sessions`(`thread_id`) ON UPDATE CASCADE ON DELETE SET NULL\n);\n\nCREATE TABLE IF NOT EXISTS `session_sleeps` (\n\t`session_id` text PRIMARY KEY,\n\t`wake_at` datetime NOT NULL,\n\t`reason` text,\n\t`status` text DEFAULT 'planned' NOT NULL,\n\t`delivery_id` text NOT NULL,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`last_attempt_at` datetime,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE IF NOT EXISTS `thread_sessions` (\n\t`thread_id` text PRIMARY KEY,\n\t`session_id` text NOT NULL,\n\t`source` text DEFAULT 'kimaki' NOT NULL,\n\t`last_synced_name` text,\n\t`parent_session_id` text,\n\t`created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n\t`updated_at` datetime DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS `scheduled_tasks_status_next_run_at_idx` ON `scheduled_tasks` (`status`,`next_run_at`);\nCREATE INDEX IF NOT EXISTS `scheduled_tasks_channel_id_status_idx` ON `scheduled_tasks` (`channel_id`,`status`);\nCREATE INDEX IF NOT EXISTS `scheduled_tasks_thread_id_status_idx` ON `scheduled_tasks` (`thread_id`,`status`);\nCREATE INDEX IF NOT EXISTS `session_sleeps_status_wake_at_idx` ON `session_sleeps` (`status`,`wake_at`);"
package/src/schema.ts CHANGED
@@ -124,6 +124,14 @@ export const channel_mention_mode = s.sqliteTable('channel_mention_mode', {
124
124
  updated_at: datetime('updated_at').default(orm.sql`CURRENT_TIMESTAMP`).$onUpdate(() => new Date()),
125
125
  })
126
126
 
127
+ // /toggle-coordinator choice of a project channel. Missing: the `--coordinator` default.
128
+ export const channel_coordinator = s.sqliteTable('channel_coordinator', {
129
+ channel_id: s.text('channel_id').primaryKey().notNull().references(() => channel_directories.channel_id, { onUpdate: 'cascade' }),
130
+ enabled: s.integer('enabled', { mode: 'number' }).notNull(),
131
+ created_at: datetime('created_at').default(orm.sql`CURRENT_TIMESTAMP`),
132
+ updated_at: datetime('updated_at').default(orm.sql`CURRENT_TIMESTAMP`).$onUpdate(() => new Date()),
133
+ })
134
+
127
135
  export const scheduled_tasks = s.sqliteTable('scheduled_tasks', {
128
136
  id: s.integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }).notNull(),
129
137
  status: s.text('status', { enum: ['planned', 'running', 'completed', 'cancelled', 'failed'] }).notNull().default('planned'),
@@ -176,6 +184,7 @@ export const relations = defineRelations({
176
184
  channel_worktrees,
177
185
  channel_verbosity,
178
186
  channel_mention_mode,
187
+ channel_coordinator,
179
188
  scheduled_tasks,
180
189
  session_sleeps,
181
190
  }, (r) => ({
@@ -203,6 +212,7 @@ export const relations = defineRelations({
203
212
  channel_worktree: r.one.channel_worktrees({ from: r.channel_directories.channel_id, to: r.channel_worktrees.channel_id }),
204
213
  channel_verbosity: r.one.channel_verbosity({ from: r.channel_directories.channel_id, to: r.channel_verbosity.channel_id }),
205
214
  channel_mention_mode: r.one.channel_mention_mode({ from: r.channel_directories.channel_id, to: r.channel_mention_mode.channel_id }),
215
+ channel_coordinator: r.one.channel_coordinator({ from: r.channel_directories.channel_id, to: r.channel_coordinator.channel_id }),
206
216
  scheduled_tasks: r.many.scheduled_tasks(),
207
217
  },
208
218
  channel_models: {
@@ -220,6 +230,9 @@ export const relations = defineRelations({
220
230
  channel_mention_mode: {
221
231
  channel: r.one.channel_directories({ from: r.channel_mention_mode.channel_id, to: r.channel_directories.channel_id }),
222
232
  },
233
+ channel_coordinator: {
234
+ channel: r.one.channel_directories({ from: r.channel_coordinator.channel_id, to: r.channel_directories.channel_id }),
235
+ },
223
236
  scheduled_tasks: {
224
237
  channel: r.one.channel_directories({ from: r.scheduled_tasks.channel_id, to: r.channel_directories.channel_id }),
225
238
  thread: r.one.thread_sessions({ from: r.scheduled_tasks.thread_id, to: r.thread_sessions.thread_id }),