kimaki 1.4.0 → 1.6.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 (326) 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 +10 -1
  15. package/dist/bot.js +14 -6
  16. package/dist/bot.js.map +1 -1
  17. package/dist/btw.e2e.test.js +3 -3
  18. package/dist/builtin-skills.d.ts +1 -1
  19. package/dist/builtin-skills.js +1 -1
  20. package/dist/builtin-skills.js.map +1 -1
  21. package/dist/builtin-skills.test.js +3 -4
  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.js +25 -14
  25. package/dist/cli/bot.js.map +1 -1
  26. package/dist/cli/discord.js +32 -6
  27. package/dist/cli/discord.js.map +1 -1
  28. package/dist/cli/schedule.js +23 -9
  29. package/dist/cli/schedule.js.map +1 -1
  30. package/dist/cli/session.js +37 -12
  31. package/dist/cli/session.js.map +1 -1
  32. package/dist/cli/shared.d.ts +5 -3
  33. package/dist/cli/shared.js +45 -10
  34. package/dist/cli/shared.js.map +1 -1
  35. package/dist/cli-discord.e2e.test.js +13 -8
  36. package/dist/cli-discord.e2e.test.js.map +1 -1
  37. package/dist/cli-lock.e2e.test.js +87 -20
  38. package/dist/cli-lock.e2e.test.js.map +1 -1
  39. package/dist/cli-read.e2e.test.js +18 -9
  40. package/dist/cli-read.e2e.test.js.map +1 -1
  41. package/dist/cli-sessions.e2e.test.js +4 -3
  42. package/dist/cli-sessions.e2e.test.js.map +1 -1
  43. package/dist/cli.js +2 -0
  44. package/dist/cli.js.map +1 -1
  45. package/dist/commands/preference-commands.d.ts +4 -0
  46. package/dist/commands/preference-commands.js +38 -4
  47. package/dist/commands/preference-commands.js.map +1 -1
  48. package/dist/commands/session-commands.js +11 -8
  49. package/dist/commands/session-commands.js.map +1 -1
  50. package/dist/commands/thread-commands.js +94 -24
  51. package/dist/commands/thread-commands.js.map +1 -1
  52. package/dist/commands/worktree-commands.d.ts +4 -5
  53. package/dist/commands/worktree-commands.js +8 -7
  54. package/dist/commands/worktree-commands.js.map +1 -1
  55. package/dist/commands.e2e.test.js +25 -21
  56. package/dist/commands.e2e.test.js.map +1 -1
  57. package/dist/coordinator-agents.e2e.test.js +135 -0
  58. package/dist/coordinator-agents.e2e.test.js.map +1 -0
  59. package/dist/coordinator-idle.e2e.test.js +100 -0
  60. package/dist/coordinator-idle.e2e.test.js.map +1 -0
  61. package/dist/coordinator-queue.e2e.test.js +244 -0
  62. package/dist/coordinator-queue.e2e.test.js.map +1 -0
  63. package/dist/coordinator-warmup.e2e.test.d.ts +1 -0
  64. package/dist/coordinator-warmup.e2e.test.js +131 -0
  65. package/dist/coordinator-warmup.e2e.test.js.map +1 -0
  66. package/dist/coordinator.d.ts +6 -0
  67. package/dist/coordinator.e2e.test.d.ts +1 -0
  68. package/dist/coordinator.e2e.test.js +505 -0
  69. package/dist/coordinator.e2e.test.js.map +1 -0
  70. package/dist/coordinator.js +164 -0
  71. package/dist/coordinator.js.map +1 -0
  72. package/dist/db.d.ts +75 -0
  73. package/dist/db.test.js +1 -0
  74. package/dist/db.test.js.map +1 -1
  75. package/dist/diagram.d.ts +11 -0
  76. package/dist/diagram.js +232 -0
  77. package/dist/diagram.js.map +1 -0
  78. package/dist/diagram.test.d.ts +1 -0
  79. package/dist/diagram.test.js +52 -0
  80. package/dist/diagram.test.js.map +1 -0
  81. package/dist/duration.js +13 -5
  82. package/dist/duration.js.map +1 -1
  83. package/dist/effects.d.ts +6 -1
  84. package/dist/effects.js +97 -26
  85. package/dist/effects.js.map +1 -1
  86. package/dist/event-loop.d.ts +1 -1
  87. package/dist/event-loop.js +73 -43
  88. package/dist/event-loop.js.map +1 -1
  89. package/dist/format-parts.d.ts +21 -4
  90. package/dist/format-parts.js +40 -23
  91. package/dist/format-parts.js.map +1 -1
  92. package/dist/gateway-replay.e2e.test.js +4 -4
  93. package/dist/harness.e2e.test.js +2 -2
  94. package/dist/harness.e2e.test.js.map +1 -1
  95. package/dist/hello-thread.e2e.test.js +56 -5
  96. package/dist/hello-thread.e2e.test.js.map +1 -1
  97. package/dist/ingress.js +68 -13
  98. package/dist/ingress.js.map +1 -1
  99. package/dist/interaction-context.d.ts +4 -4
  100. package/dist/interaction-context.js +9 -7
  101. package/dist/interaction-context.js.map +1 -1
  102. package/dist/interrupt.e2e.test.js +8 -5
  103. package/dist/interrupt.e2e.test.js.map +1 -1
  104. package/dist/legacy.e2e.test.js +1 -1
  105. package/dist/lock-routes.d.ts +68 -16
  106. package/dist/lock-routes.js +68 -8
  107. package/dist/lock-routes.js.map +1 -1
  108. package/dist/lock-server.d.ts +3 -2
  109. package/dist/lock-server.js +23 -7
  110. package/dist/lock-server.js.map +1 -1
  111. package/dist/main.d.ts +4 -2
  112. package/dist/main.js +13 -3
  113. package/dist/main.js.map +1 -1
  114. package/dist/markdown/markdown.test.js +4 -4
  115. package/dist/markdown/render-markdown.js +6 -5
  116. package/dist/markdown/render-markdown.js.map +1 -1
  117. package/dist/onboarding-cli.e2e.test.js +7 -6
  118. package/dist/onboarding-cli.e2e.test.js.map +1 -1
  119. package/dist/onboarding.d.ts +3 -10
  120. package/dist/onboarding.e2e.test.js +8 -10
  121. package/dist/onboarding.e2e.test.js.map +1 -1
  122. package/dist/onboarding.js +14 -24
  123. package/dist/onboarding.js.map +1 -1
  124. package/dist/permissions.e2e.test.js +3 -3
  125. package/dist/plugin/coordinator.d.ts +4 -0
  126. package/dist/plugin/coordinator.js +389 -0
  127. package/dist/plugin/coordinator.js.map +1 -0
  128. package/dist/plugin/index.js +76 -17
  129. package/dist/plugin/index.js.map +1 -1
  130. package/dist/plugin.e2e.test.js +1 -1
  131. package/dist/preference-commands.e2e.test.js +103 -24
  132. package/dist/preference-commands.e2e.test.js.map +1 -1
  133. package/dist/prompt.d.ts +21 -8
  134. package/dist/prompt.js +64 -48
  135. package/dist/prompt.js.map +1 -1
  136. package/dist/questions.e2e.test.js +13 -13
  137. package/dist/queue-sources.e2e.test.d.ts +1 -0
  138. package/dist/queue-sources.e2e.test.js +168 -0
  139. package/dist/queue-sources.e2e.test.js.map +1 -0
  140. package/dist/queue.d.ts +9 -1
  141. package/dist/queue.e2e.test.js +48 -30
  142. package/dist/queue.e2e.test.js.map +1 -1
  143. package/dist/queue.js +97 -24
  144. package/dist/queue.js.map +1 -1
  145. package/dist/queue.test.d.ts +1 -0
  146. package/dist/queue.test.js +57 -0
  147. package/dist/queue.test.js.map +1 -0
  148. package/dist/remote-send.e2e.test.js +6 -6
  149. package/dist/remote-send.js +2 -1
  150. package/dist/remote-send.js.map +1 -1
  151. package/dist/renderer.e2e.test.js +7 -7
  152. package/dist/restart.e2e.test.js +2 -2
  153. package/dist/routines.e2e.test.js +2 -2
  154. package/dist/scheduler.d.ts +1 -1
  155. package/dist/scheduler.e2e.test.js +43 -20
  156. package/dist/scheduler.e2e.test.js.map +1 -1
  157. package/dist/scheduler.js +4 -2
  158. package/dist/scheduler.js.map +1 -1
  159. package/dist/schema-sql.d.ts +1 -1
  160. package/dist/schema-sql.js +1 -1
  161. package/dist/schema-sql.js.map +1 -1
  162. package/dist/schema.d.ts +146 -0
  163. package/dist/schema.js +12 -0
  164. package/dist/schema.js.map +1 -1
  165. package/dist/session-commands.e2e.test.js +36 -11
  166. package/dist/session-commands.e2e.test.js.map +1 -1
  167. package/dist/session-events.d.ts +19 -7
  168. package/dist/session-events.js +166 -22
  169. package/dist/session-events.js.map +1 -1
  170. package/dist/session-ops.e2e.test.js +88 -11
  171. package/dist/session-ops.e2e.test.js.map +1 -1
  172. package/dist/sessions.d.ts +41 -18
  173. package/dist/sessions.js +174 -63
  174. package/dist/sessions.js.map +1 -1
  175. package/dist/shell.e2e.test.js +4 -4
  176. package/dist/slash-commands.js +12 -6
  177. package/dist/slash-commands.js.map +1 -1
  178. package/dist/slash-commands.test.js +2 -0
  179. package/dist/slash-commands.test.js.map +1 -1
  180. package/dist/sleep.e2e.test.js +29 -14
  181. package/dist/sleep.e2e.test.js.map +1 -1
  182. package/dist/sleeps.d.ts +1 -0
  183. package/dist/sleeps.js +21 -3
  184. package/dist/sleeps.js.map +1 -1
  185. package/dist/store.d.ts +1 -0
  186. package/dist/store.js +3 -2
  187. package/dist/store.js.map +1 -1
  188. package/dist/system-prompt.d.ts +3 -1
  189. package/dist/system-prompt.js +138 -64
  190. package/dist/system-prompt.js.map +1 -1
  191. package/dist/test/generate-log-plugin/index.js +15 -0
  192. package/dist/test/generate-log-plugin/index.js.map +1 -0
  193. package/dist/test/harness.d.ts +20 -1
  194. package/dist/test/harness.js +51 -10
  195. package/dist/test/harness.js.map +1 -1
  196. package/dist/test/replay.js +11 -7
  197. package/dist/test/replay.js.map +1 -1
  198. package/dist/thread-reducer.d.ts +29 -3
  199. package/dist/thread-reducer.fixtures.test.js +58 -51
  200. package/dist/thread-reducer.fixtures.test.js.map +1 -1
  201. package/dist/thread-reducer.js +112 -25
  202. package/dist/thread-reducer.js.map +1 -1
  203. package/dist/thread-reducer.test.js +197 -24
  204. package/dist/thread-reducer.test.js.map +1 -1
  205. package/dist/voice-attachments.e2e.test.js +108 -18
  206. package/dist/voice-attachments.e2e.test.js.map +1 -1
  207. package/dist/voice-call.e2e.test.js +9 -9
  208. package/dist/voice-call.js +6 -6
  209. package/dist/voice-call.js.map +1 -1
  210. package/dist/voice-openai.e2e.test.js +59 -23
  211. package/dist/voice-openai.e2e.test.js.map +1 -1
  212. package/dist/voice.js +80 -24
  213. package/dist/voice.js.map +1 -1
  214. package/dist/working-directory.e2e.test.js +11 -11
  215. package/dist/working-directory.e2e.test.js.map +1 -1
  216. package/package.json +12 -6
  217. package/src/agent-ui.e2e.test.ts +27 -13
  218. package/src/agent-ui.ts +2 -1
  219. package/src/analytics.e2e.test.ts +1 -1
  220. package/src/autostart.ts +55 -10
  221. package/src/bot.ts +29 -5
  222. package/src/btw.e2e.test.ts +3 -3
  223. package/src/builtin-skills.test.ts +3 -4
  224. package/src/builtin-skills.ts +1 -1
  225. package/src/catalog-refresh.e2e.test.ts +2 -2
  226. package/src/cli/bot.ts +23 -14
  227. package/src/cli/discord.ts +30 -6
  228. package/src/cli/schedule.ts +23 -9
  229. package/src/cli/session.ts +35 -12
  230. package/src/cli/shared.ts +43 -8
  231. package/src/cli-discord.e2e.test.ts +13 -8
  232. package/src/cli-lock.e2e.test.ts +88 -20
  233. package/src/cli-read.e2e.test.ts +17 -8
  234. package/src/cli-sessions.e2e.test.ts +4 -3
  235. package/src/cli.ts +2 -0
  236. package/src/commands/preference-commands.ts +38 -4
  237. package/src/commands/session-commands.ts +12 -7
  238. package/src/commands/thread-commands.ts +92 -24
  239. package/src/commands/worktree-commands.ts +10 -9
  240. package/src/commands.e2e.test.ts +25 -21
  241. package/src/coordinator-agents.e2e.test.ts +168 -0
  242. package/src/coordinator-idle.e2e.test.ts +122 -0
  243. package/src/coordinator-queue.e2e.test.ts +279 -0
  244. package/src/coordinator-warmup.e2e.test.ts +158 -0
  245. package/src/coordinator.e2e.test.ts +545 -0
  246. package/src/coordinator.ts +168 -0
  247. package/src/db.test.ts +1 -0
  248. package/src/diagram.test.ts +56 -0
  249. package/src/diagram.ts +243 -0
  250. package/src/duration.ts +13 -5
  251. package/src/effects.ts +99 -27
  252. package/src/event-loop.ts +71 -42
  253. package/src/format-parts.ts +53 -22
  254. package/src/gateway-replay.e2e.test.ts +4 -4
  255. package/src/harness.e2e.test.ts +2 -2
  256. package/src/hello-thread.e2e.test.ts +59 -5
  257. package/src/ingress.ts +62 -12
  258. package/src/interaction-context.ts +12 -11
  259. package/src/interrupt.e2e.test.ts +8 -5
  260. package/src/legacy.e2e.test.ts +1 -1
  261. package/src/lock-routes.ts +62 -8
  262. package/src/lock-server.ts +23 -7
  263. package/src/main.ts +22 -7
  264. package/src/markdown/markdown.test.ts +4 -4
  265. package/src/markdown/render-markdown.ts +6 -5
  266. package/src/onboarding-cli.e2e.test.ts +7 -6
  267. package/src/onboarding.e2e.test.ts +8 -10
  268. package/src/onboarding.ts +13 -29
  269. package/src/permissions.e2e.test.ts +3 -3
  270. package/src/plugin/coordinator.ts +401 -0
  271. package/src/plugin/index.ts +68 -15
  272. package/src/plugin.e2e.test.ts +1 -1
  273. package/src/preference-commands.e2e.test.ts +103 -23
  274. package/src/prompt.ts +87 -43
  275. package/src/questions.e2e.test.ts +13 -13
  276. package/src/queue-sources.e2e.test.ts +197 -0
  277. package/src/queue.e2e.test.ts +48 -30
  278. package/src/queue.test.ts +59 -0
  279. package/src/queue.ts +104 -24
  280. package/src/remote-send.e2e.test.ts +6 -6
  281. package/src/remote-send.ts +2 -1
  282. package/src/renderer.e2e.test.ts +7 -7
  283. package/src/restart.e2e.test.ts +2 -2
  284. package/src/routines.e2e.test.ts +2 -2
  285. package/src/scheduler.e2e.test.ts +45 -19
  286. package/src/scheduler.ts +4 -2
  287. package/src/schema-sql.ts +1 -1
  288. package/src/schema.ts +13 -0
  289. package/src/session-commands.e2e.test.ts +38 -11
  290. package/src/session-events.ts +160 -21
  291. package/src/session-ops.e2e.test.ts +90 -10
  292. package/src/sessions.ts +214 -64
  293. package/src/shell.e2e.test.ts +4 -4
  294. package/src/slash-commands.test.ts +2 -0
  295. package/src/slash-commands.ts +12 -6
  296. package/src/sleep.e2e.test.ts +30 -13
  297. package/src/sleeps.ts +20 -3
  298. package/src/store.ts +5 -2
  299. package/src/system-prompt.ts +143 -63
  300. package/src/test/generate-log-plugin/index.ts +16 -0
  301. package/src/test/harness.ts +77 -14
  302. package/src/test/replay.ts +12 -6
  303. package/src/thread-reducer.fixtures.test.ts +58 -51
  304. package/src/thread-reducer.test.ts +207 -26
  305. package/src/thread-reducer.ts +138 -37
  306. package/src/voice-attachments.e2e.test.ts +110 -18
  307. package/src/voice-call.e2e.test.ts +9 -9
  308. package/src/voice-call.ts +6 -6
  309. package/src/voice-openai.e2e.test.ts +64 -24
  310. package/src/voice.ts +101 -26
  311. package/src/working-directory.e2e.test.ts +11 -11
  312. package/dist/message-tool.e2e.test.js +0 -39
  313. package/dist/message-tool.e2e.test.js.map +0 -1
  314. package/dist/plugin/read-media.d.ts +0 -23
  315. package/dist/plugin/read-media.js +0 -72
  316. package/dist/plugin/read-media.js.map +0 -1
  317. package/dist/plugin/session-id/index.js +0 -18
  318. package/dist/plugin/session-id/index.js.map +0 -1
  319. package/dist/probe-live.tmp.js +0 -82
  320. package/dist/probe-live.tmp.js.map +0 -1
  321. package/dist/tasks.e2e.test.js +0 -173
  322. package/dist/tasks.e2e.test.js.map +0 -1
  323. /package/dist/{message-tool.e2e.test.d.ts → coordinator-agents.e2e.test.d.ts} +0 -0
  324. /package/dist/{probe-live.tmp.d.ts → coordinator-idle.e2e.test.d.ts} +0 -0
  325. /package/dist/{tasks.e2e.test.d.ts → coordinator-queue.e2e.test.d.ts} +0 -0
  326. /package/dist/{plugin/session-id → test/generate-log-plugin}/index.d.ts +0 -0
@@ -90,7 +90,7 @@ test('a second bot on the same lock port stops the running one and takes over',
90
90
  import { disabledAnalytics } from ${JSON.stringify(path.resolve('src/analytics.ts'))}
91
91
  const lock = await startLockServer({ port: ${port}, dataDir: ${JSON.stringify(otherData)} })
92
92
  if (lock instanceof Error) throw lock
93
- const result = await startBot({ analytics: disabledAnalytics, lock, ...${JSON.stringify({ dataDir: otherData, token: twin.discord.botToken, appId: twin.discord.botUserId, discordRestUrl: twin.discord.restUrl, opencodeServiceFile: server.serviceFile, opencodeConfigDir: server.configDir, opencodeDbFile: server.dbFile, ensureOpencode: false, kimakiCommand: 'kimaki' })} })
93
+ const result = await startBot({ analytics: disabledAnalytics, lock, ...${JSON.stringify({ dataDir: otherData, token: twin.discord.botToken, appId: twin.discord.botUserId, discordRestUrl: twin.discord.restUrl, opencodeServiceFile: server.serviceFile, opencodeConfigDir: server.configDir, opencodeDbFile: server.dbFile, ensureOpencode: false, kimakiArgv: ['kimaki'] })} })
94
94
  if (result instanceof Error) throw result
95
95
  process.send({ ready: true })
96
96
  process.once('SIGTERM', async () => { await result.stop(); process.exit(0) })
@@ -119,6 +119,6 @@ test('a second bot on the same lock port stops the running one and takes over',
119
119
  --- from: assistant (TestBot)
120
120
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
121
121
  ok
122
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
122
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
123
123
  `)
124
124
  })
@@ -29,6 +29,8 @@ let bot: BotHandle
29
29
  let dataDir: string
30
30
  let helloThreadId: string
31
31
 
32
+ const MEDIA_URL = 'https://cdn.discordapp.com/attachments/1012345678900020080/1234567891233211234/shot.png?ex=65d903de&is=65c68ede&hm=2481f30d&'
33
+
32
34
  beforeAll(async () => {
33
35
  dataDir = tempDataDir()
34
36
  ;[server, twin] = await Promise.all([
@@ -60,6 +62,19 @@ beforeAll(async () => {
60
62
  ],
61
63
  },
62
64
  },
65
+ {
66
+ id: 'media',
67
+ priority: 10,
68
+ when: { latestUserTextIncludes: 'media-marker' },
69
+ then: {
70
+ parts: [
71
+ { type: 'text-start', id: 't3' },
72
+ { type: 'text-delta', id: 't3', delta: `Here is the screenshot: ${MEDIA_URL}` },
73
+ { type: 'text-end', id: 't3' },
74
+ { type: 'finish', finishReason: 'stop', usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } },
75
+ ],
76
+ },
77
+ },
63
78
  ],
64
79
  }),
65
80
  startTwin(),
@@ -106,12 +121,12 @@ test('channel message creates a thread with reply and footer, follow-up continue
106
121
  --- from: assistant (TestBot)
107
122
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
108
123
  Hello from the deterministic model.
109
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
124
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
110
125
  --- from: user (tommy)
111
126
  Again please second-marker
112
127
  --- from: assistant (TestBot)
113
128
  Second answer, same session.
114
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
129
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
115
130
  `)
116
131
  expect(thread.name).toBe('Say hello hello-marker')
117
132
 
@@ -139,7 +154,7 @@ test('channel message creates a thread with reply and footer, follow-up continue
139
154
  {
140
155
  "silent": false,
141
156
  "suppressEmbeds": true,
142
- "text": "-# *project ⋅ main ⋅",
157
+ "text": "-# *main ⋅ <1s ⋅ 0% ",
143
158
  },
144
159
  {
145
160
  "silent": true,
@@ -149,7 +164,7 @@ test('channel message creates a thread with reply and footer, follow-up continue
149
164
  {
150
165
  "silent": false,
151
166
  "suppressEmbeds": true,
152
- "text": "-# *project ⋅ main ⋅",
167
+ "text": "-# *main ⋅ <1s ⋅ 0% ",
153
168
  },
154
169
  ]
155
170
  `)
@@ -170,6 +185,45 @@ test('channel message creates a thread with reply and footer, follow-up continue
170
185
  expect(session.time.idle).toBeGreaterThan(0)
171
186
  })
172
187
 
188
+ test('a reply with a Discord media URL keeps its embed, other lines stay suppressed', async () => {
189
+ const { discord, channelId } = twin
190
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Show the shot media-marker' })
191
+ const thread = await discord.channel(channelId).waitForThread({ predicate: (thread) => thread.name?.startsWith('Show the shot') ?? false })
192
+ await waitForFooter({ discord, threadId: thread.id })
193
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
194
+ "--- from: user (tommy)
195
+ Show the shot media-marker
196
+ --- from: assistant (TestBot)
197
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
198
+ Here is the screenshot: https://cdn.discordapp.com/attachments/1012345678900020080/1234567891233211234/shot.png?ex=65d903de&is=65c68ede&hm=2481f30d&
199
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
200
+ `)
201
+
202
+ const messages = await discord.thread(thread.id).getMessages()
203
+ const flags = messages
204
+ .filter((message) => message.author.bot)
205
+ .map((message) => ({
206
+ text: message.content.slice(0, 20),
207
+ suppressEmbeds: Boolean((message.flags ?? 0) & MessageFlags.SuppressEmbeds),
208
+ }))
209
+ expect(flags).toMatchInlineSnapshot(`
210
+ [
211
+ {
212
+ "suppressEmbeds": true,
213
+ "text": "-# *using determinis",
214
+ },
215
+ {
216
+ "suppressEmbeds": false,
217
+ "text": "Here is the screensh",
218
+ },
219
+ {
220
+ "suppressEmbeds": true,
221
+ "text": "-# *main ⋅ <1s ⋅ 0% ",
222
+ },
223
+ ]
224
+ `)
225
+ })
226
+
173
227
  test('message from a user without permission creates no thread', async () => {
174
228
  const { discord, channelId } = twin
175
229
  const before = (await discord.channel(channelId).getThreads()).length
@@ -203,7 +257,7 @@ test('another bot needs the Kimaki role, and no-kimaki still denies it', async (
203
257
  --- from: assistant (TestBot)
204
258
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
205
259
  Hello from the deterministic model.
206
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
260
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
207
261
  `)
208
262
 
209
263
  const withRole = (await discord.channel(channelId).getThreads()).length
package/src/ingress.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Discord ingress (spec 4): gates every messageCreate, parses it into a Route
2
2
  // and calls actions. Gates, in order: webhooks and our own messages (except
3
- // remote send envelopes), channel ownership (only channels mapped in this
3
+ // remote send envelopes), system messages (a thread rename only sets the
4
+ // session title), channel ownership (only channels mapped in this
4
5
  // machine's SQLite), permission (other bots need the Kimaki role).
5
6
  // Messages of one channel are handled in arrival order, after OpenCode is ready.
6
7
  //
@@ -17,9 +18,11 @@ import fs from 'node:fs'
17
18
  import path from 'node:path'
18
19
  import { pathToFileURL } from 'node:url'
19
20
  import {
21
+ ChannelType,
20
22
  Events,
21
23
  GuildMember,
22
24
  MessageFlags,
25
+ MessageType,
23
26
  PermissionFlagsBits,
24
27
  type Attachment,
25
28
  type Guild,
@@ -27,18 +30,20 @@ import {
27
30
  } from 'discord.js'
28
31
  import * as errore from 'errore'
29
32
 
30
- import { projectOf, sessionDirectory, type Bot, type PromptFile } from './bot.ts'
33
+ import { isCoordinatorChannel, oc, projectOf, sessionDirectory, type Bot, type PromptFile } from './bot.ts'
31
34
  import { formatError, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './format-parts.ts'
32
35
  import { createLogger } from './logger.ts'
33
36
  import { parseInput, sendInput } from './lock-routes.ts'
34
37
  import { cancelQueuedPrompt, dispatch, requeueEdited, send } from './prompt.ts'
35
38
  import { REMOTE_PROMPT_FILE, REMOTE_RESULT_PREFIX, REMOTE_SEND_PREFIX } from './remote-send.ts'
36
- import { formatEcho, queuedItemFor } from './queue.ts'
39
+ import { echoPayloads, formatEcho, queuedItemFor } from './queue.ts'
37
40
  import { parseTextMessage, type Route } from './routes.ts'
38
- import { primaryAgents, startSession } from './sessions.ts'
41
+ import { primaryAgents, startCoordinator, startSession } from './sessions.ts'
39
42
  import { isVoiceAttachment, parseVoiceMessage, transcribe, voiceMediaType, type AttachmentLike } from './voice.ts'
40
43
 
41
44
  const logger = createLogger('INGRESS')
45
+ // Later messages of the channel wait for downloads (registerIngress).
46
+ const DOWNLOAD_TIMEOUT_MS = 60_000
42
47
 
43
48
  // Owner, Administrator, Manage Server, or a role named "Kimaki"; with
44
49
  // --allow-all-users every member. A role named "no-kimaki" always denies.
@@ -114,14 +119,14 @@ class AttachmentError extends errore.createTaggedError({
114
119
  }) {}
115
120
 
116
121
  async function remotePromptText(url: string) {
117
- const response = await fetch(url).catch((cause) => new AttachmentError({ file: REMOTE_PROMPT_FILE, cause }))
122
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }).catch((cause) => new AttachmentError({ file: REMOTE_PROMPT_FILE, cause }))
118
123
  if (response instanceof Error) return response
119
124
  if (!response.ok) return new AttachmentError({ file: REMOTE_PROMPT_FILE, cause: new Error(`HTTP ${response.status}`) })
120
125
  return response.text().catch((cause) => new AttachmentError({ file: REMOTE_PROMPT_FILE, cause }))
121
126
  }
122
127
 
123
128
  async function download(url: string): Promise<AttachmentError | Buffer> {
124
- const response = await fetch(url).catch((e) => new AttachmentError({ file: url, cause: e }))
129
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }).catch((e) => new AttachmentError({ file: url, cause: e }))
125
130
  if (response instanceof Error) return response
126
131
  if (!response.ok) return new AttachmentError({ file: `${url} (HTTP ${response.status})` })
127
132
  const body = await response.arrayBuffer().catch((e) => new AttachmentError({ file: url, cause: e }))
@@ -192,7 +197,7 @@ async function handleRemoteEnvelope(bot: Bot, { message, channelId, threadId }:
192
197
  const input = prompt instanceof Error ? prompt : parseInput(sendInput, { ...value.options, ...target, prompt })
193
198
  const attachments = [...message.attachments.values()].filter((attachment) => attachment !== promptAttachment)
194
199
  const files = await saveAttachments({ dataDir: bot.dataDir, messageId: message.id, attachments })
195
- const result = input instanceof Error ? input : files instanceof Error ? files : await send(bot, { ...input, files }, { localOnly: true })
200
+ const result = input instanceof Error ? input : files instanceof Error ? files : await send(bot, { ...input, files }, { localOnly: true, shownIn: message.id })
196
201
  const answer = JSON.stringify(result instanceof Error ? { error: result.message } : result)
197
202
  await message
198
203
  .reply({
@@ -232,7 +237,9 @@ async function handleMessage(bot: Bot, message: Message) {
232
237
  logger.log(`ignoring ${message.author.username}: no Kimaki permission`)
233
238
  return
234
239
  }
235
- const sessionId = thread ? bot.store.getState().roots[thread.id] : undefined
240
+ // A coordinator channel has its root session under the channel ID.
241
+ const coordinator = !thread && isCoordinatorChannel(bot, channelId)
242
+ const sessionId = bot.store.getState().roots[thread?.id ?? (coordinator ? channelId : '')]
236
243
  if (thread && !sessionId) return
237
244
  const author = { id: message.author.id, username: message.author.username }
238
245
  const reportError = async (error: Error) => {
@@ -259,8 +266,35 @@ async function handleMessage(bot: Bot, message: Message) {
259
266
  const attached = files.length > 0 ? `, ${files.length} file(s)` : ''
260
267
  logger.log(`${route.kind}${detail} from ${author.username} in ${thread ? `thread ${thread.id}` : `channel ${channelId}`}${attached}`)
261
268
  // The transcription is not visible anywhere else.
262
- if (voice && thread && route.kind !== 'shell' && route.kind !== 'command' && route.kind !== 'skill') {
263
- await message.reply({ content: formatEcho({ username: author.username, text: route.text }), allowedMentions: { parse: [] }, flags: SILENT_MESSAGE_FLAGS })
269
+ if (voice && (thread || coordinator) && route.kind !== 'shell' && route.kind !== 'command' && route.kind !== 'skill') {
270
+ // A long transcript continues in more messages after the reply.
271
+ for (const [index, payload] of echoPayloads(formatEcho({ username: author.username, text: route.text })).entries()) {
272
+ const options = { ...payload, allowedMentions: { parse: [] }, flags: ('flags' in payload ? payload.flags : 0) | SILENT_MESSAGE_FLAGS }
273
+ if (index === 0) await message.reply(options)
274
+ else if (channel.isSendable()) await channel.send(options)
275
+ }
276
+ }
277
+
278
+ // Coordinator: the channel is the conversation of one long session.
279
+ if (coordinator && channel.type === ChannelType.GuildText) {
280
+ // A spoken agent is for a new thread: the coordinator keeps its own agent and model.
281
+ const result = route.kind === 'new-session' || (route.kind === 'steer' && route.agent)
282
+ ? await startSession(bot, { channelId, directory: project.directory, route: { kind: 'steer', text: route.text, ...(route.agent && { agent: route.agent }) }, author, messageId: message.id, startMessageId: null, files })
283
+ : bot.store.getState().roots[channel.id]
284
+ ? await dispatch(bot, { conversation: channel, route, author, messageId: message.id, files })
285
+ : await startCoordinator(bot, {
286
+ channel,
287
+ // Queue and btw need a session to wait for or fork from: the first input is a plain prompt.
288
+ route: route.kind === 'shell' || route.kind === 'command' || route.kind === 'skill' || route.kind === 'steer' ? route : { kind: 'steer', text: route.text },
289
+ author,
290
+ messageId: message.id,
291
+ files,
292
+ })
293
+ if (result instanceof Error) return reportError(result)
294
+ if (!result || result.threadId === channel.id) return
295
+ const note = route.kind === 'btw' ? `Session forked! Continue in <#${result.threadId}>` : `Started a new session in <#${result.threadId}>`
296
+ await message.reply({ content: note, allowedMentions: { repliedUser: false }, flags: SILENT_MESSAGE_FLAGS }).catch(() => undefined)
297
+ return
264
298
  }
265
299
 
266
300
  if (!thread) {
@@ -280,7 +314,7 @@ async function handleMessage(bot: Bot, message: Message) {
280
314
  return
281
315
  }
282
316
 
283
- const result = await dispatch(bot, { thread, route, author, messageId: message.id, files })
317
+ const result = await dispatch(bot, { conversation: thread, route, author, messageId: message.id, files })
284
318
  if (result instanceof Error) return reportError(result)
285
319
  if (!result) return
286
320
  const note =
@@ -288,7 +322,20 @@ async function handleMessage(bot: Bot, message: Message) {
288
322
  await message.reply({ content: note, allowedMentions: { repliedUser: false }, flags: SILENT_MESSAGE_FLAGS }).catch(() => undefined)
289
323
  }
290
324
 
291
- // Registers the message listeners. Messages of one channel are handled in arrival order.
325
+ // A rename in the Discord UI posts a CHANNEL_NAME_CHANGE message with the new
326
+ // name as content (issue #258): the session title follows it, nothing is prompted.
327
+ async function handleThreadRename(bot: Bot, message: Message) {
328
+ if (!message.inGuild() || !message.channel.isThread()) return
329
+ const sessionId = bot.store.getState().roots[message.channel.id]
330
+ if (!sessionId || !(await authorCanUseKimaki(bot, message))) return
331
+ const title = message.content
332
+ const renamed = await oc(bot, 'session.update', (client) => client.session.update({ sessionID: sessionId, title }))
333
+ if (renamed instanceof Error) return logger.warn(`title of ${sessionId} from thread rename`, renamed)
334
+ logger.log(`session ${sessionId} renamed to "${title}" by ${message.author.username}`)
335
+ }
336
+
337
+ // Registers the message listeners. Messages of one channel are handled in arrival order:
338
+ // a text sent while a voice message is transcribed waits for it (voice-attachments.e2e.test.ts).
292
339
  export function registerIngress(bot: Bot) {
293
340
  const chains = new Map<string, Promise<void>>()
294
341
 
@@ -308,6 +355,9 @@ export function registerIngress(bot: Bot) {
308
355
  // Webhooks have no member to check. Own messages only matter as remote send envelopes.
309
356
  if (message.webhookId) return
310
357
  if (message.author.id === bot.discord.user?.id && !message.embeds[0]?.footer?.text.startsWith(REMOTE_SEND_PREFIX)) return
358
+ if (message.type === MessageType.ChannelNameChange) return serialize(message.channelId, () => handleThreadRename(bot, message))
359
+ // Pins, joins, boosts and other system messages are never prompts.
360
+ if (message.type !== MessageType.Default && message.type !== MessageType.Reply) return
311
361
  serialize(message.channelId, () => handleMessage(bot, message))
312
362
  })
313
363
  // Deleting a queued Discord message removes it from the queue.
@@ -16,14 +16,13 @@ import {
16
16
  type ModalSubmitInteraction,
17
17
  type RESTPostAPIChatInputApplicationCommandsJSONBody,
18
18
  type StringSelectMenuInteraction,
19
- type ThreadChannel,
20
19
  } from 'discord.js'
21
20
 
22
- import { projectOf, sessionDirectory, type Author, type Bot } from './bot.ts'
21
+ import { isCoordinatorChannel, projectOf, sessionDirectory, type Author, type Bot, type Conversation } from './bot.ts'
23
22
  import { ConfigError, DiscordError } from './errors.ts'
24
23
  import { formatError, SILENT_MESSAGE_FLAGS } from './format-parts.ts'
25
24
  import { createLogger } from './logger.ts'
26
- import { formatEcho } from './queue.ts'
25
+ import { formatEchoReply } from './queue.ts'
27
26
 
28
27
  const logger = createLogger('COMMANDS')
29
28
 
@@ -51,11 +50,11 @@ export type InteractionRoutes = {
51
50
  // --- Context
52
51
 
53
52
  // Where an interaction happens: the project channel and, in a session
54
- // thread, the thread and its root session.
53
+ // thread or a coordinator channel, that conversation and its root session.
55
54
  export type InteractionTarget = {
56
55
  channelId: string
57
56
  directory: string
58
- thread: ThreadChannel | null
57
+ conversation: Conversation | null
59
58
  sessionId: string | null
60
59
  projectDirectory: string
61
60
  }
@@ -76,13 +75,15 @@ export async function resolveTarget(bot: Bot, channelId: string | null): Promise
76
75
  const row = await projectOf(bot, projectChannelId)
77
76
  if (row instanceof Error) return row
78
77
  if (!row) return new ConfigError({ reason: 'This channel is not configured with a project directory' })
79
- const sessionId = thread ? (bot.store.getState().roots[thread.id] ?? null) : null
78
+ // A coordinator channel is the conversation of its root session. With coordinator mode off its binding is ignored, like in ingress.
79
+ const conversation = thread ?? (channel?.type === ChannelType.GuildText && isCoordinatorChannel(bot, channel.id) && bot.store.getState().roots[channel.id] ? channel : null)
80
+ const sessionId = conversation ? (bot.store.getState().roots[conversation.id] ?? null) : null
80
81
  const directory = sessionId ? await sessionDirectory(bot, sessionId) : row.directory
81
82
  if (directory instanceof Error) return directory
82
- return { channelId: projectChannelId, directory, projectDirectory: row.directory, thread, sessionId }
83
+ return { channelId: projectChannelId, directory, projectDirectory: row.directory, conversation, sessionId }
83
84
  }
84
85
 
85
- export type SessionTarget = InteractionTarget & { thread: ThreadChannel; sessionId: string }
86
+ export type SessionTarget = InteractionTarget & { conversation: Conversation; sessionId: string }
86
87
 
87
88
  // The session thread of a command, or null after a reply saying where it works.
88
89
  export async function sessionTarget(bot: Bot, interaction: ChatInputCommandInteraction): Promise<SessionTarget | null> {
@@ -91,11 +92,11 @@ export async function sessionTarget(bot: Bot, interaction: ChatInputCommandInter
91
92
  await replyError(interaction, target)
92
93
  return null
93
94
  }
94
- if (!target.thread || !target.sessionId) {
95
+ if (!target.conversation || !target.sessionId) {
95
96
  await interaction.reply({ content: 'Use this command in a thread with a Kimaki session', flags: MessageFlags.Ephemeral })
96
97
  return null
97
98
  }
98
- return { ...target, thread: target.thread, sessionId: target.sessionId }
99
+ return { ...target, conversation: target.conversation, sessionId: target.sessionId }
99
100
  }
100
101
 
101
102
  // --- Replies
@@ -116,7 +117,7 @@ export async function replyError(
116
117
 
117
118
  // The visible reply stands in for the user message: acks and prompts point at its ID.
118
119
  export async function replyWithEcho(interaction: ChatInputCommandInteraction, { author, text }: { author: Author; text: string }) {
119
- await interaction.reply({ content: formatEcho({ username: author.username, text }), allowedMentions: { parse: [] }, flags: SILENT_MESSAGE_FLAGS })
120
+ await interaction.reply({ content: formatEchoReply({ username: author.username, text }), allowedMentions: { parse: [] }, flags: SILENT_MESSAGE_FLAGS })
120
121
  return interaction.fetchReply().catch((e: Error) => e)
121
122
  }
122
123
 
@@ -97,7 +97,7 @@ test('plain message interrupts the run and is answered', async () => {
97
97
  Stop and do this steer-marker
98
98
  --- from: assistant (TestBot)
99
99
  steer ok
100
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
100
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
101
101
  `)
102
102
  await expectNever({ threadId: thread.id, text: 'slow-done' })
103
103
  })
@@ -112,7 +112,7 @@ test('/abort stops the run and clears the queue', async () => {
112
112
  await waitForBotMessageContaining({ discord: twin.discord, threadId: thread.id, text: 'Removed from queue' })
113
113
  await expectNever({ threadId: thread.id, text: 'slow-done' })
114
114
  await expectNever({ threadId: thread.id, text: 'queued one ok' })
115
- expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
115
+ expect(await twin.discord.thread(thread.id).text({ showReplies: true })).toMatchInlineSnapshot(`
116
116
  "--- from: user (tommy)
117
117
  Abort me slow-marker
118
118
  --- from: assistant (TestBot)
@@ -120,6 +120,7 @@ test('/abort stops the run and clears the queue', async () => {
120
120
  --- from: user (tommy)
121
121
  Never runs queued-one. queue
122
122
  --- from: assistant (TestBot)
123
+ [reply to tommy: Never runs queued-one. queue]
123
124
  -# Removed from queue
124
125
  Request **aborted**, cleared 1 queued message"
125
126
  `)
@@ -134,7 +135,7 @@ test('a restarted bot still echoes and runs the queue it did not see', async ()
134
135
  bot = await startTestBot({ dataDir, twin, server })
135
136
  await waitForBotMessageContaining({ discord: twin.discord, threadId: thread.id, text: 'queued one ok' })
136
137
  await waitForFooter({ discord: twin.discord, threadId: thread.id })
137
- expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
138
+ expect(await twin.discord.thread(thread.id).text({ showReplies: true })).toMatchInlineSnapshot(`
138
139
  "--- from: user (tommy)
139
140
  Restart during queue slow-marker
140
141
  --- from: assistant (TestBot)
@@ -142,10 +143,12 @@ test('a restarted bot still echoes and runs the queue it did not see', async ()
142
143
  --- from: user (tommy)
143
144
  Survives restart queued-one. queue
144
145
  --- from: assistant (TestBot)
146
+ [reply to tommy: Survives restart queued-one. queue]
145
147
  -# Queued at position 1. Delete the original message to remove it, or use /clear-queue position:1
146
148
  slow-done
147
- » **tommy:** Survives restart queued-one
149
+ [reply to tommy: Survives restart queued-one. queue]
150
+ -# Queued message sent
148
151
  queued one ok
149
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
152
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
150
153
  `)
151
154
  })
@@ -82,7 +82,7 @@ test('bot starts on a V1 database and answers in an old thread with the old sess
82
82
  hello again after the upgrade
83
83
  --- from: assistant (TestBot)
84
84
  ok
85
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
85
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
86
86
  `)
87
87
 
88
88
  const messages = await client.message.list({ sessionID: session.id })
@@ -13,7 +13,7 @@ import * as orm from 'drizzle-orm'
13
13
  import { z } from 'zod'
14
14
 
15
15
  import { parseButton } from './agent-ui.ts'
16
- import { fetchThread, oc, parseModel, projectOf, type Bot } from './bot.ts'
16
+ import { fetchConversation, isCoordinatorChannel, oc, parseModel, projectOf, type Bot } from './bot.ts'
17
17
  import { credential, loginCli } from './commands/login-commands.ts'
18
18
  import { setChannelAgent, setChannelModel, setGlobalModel, setVerbosity } from './commands/preference-commands.ts'
19
19
  import { channelWorktrees, manageWorktree, newWorktree, setAutoWorktrees } from './commands/worktree-commands.ts'
@@ -23,7 +23,8 @@ import { abort, cancelQueuedPrompt, clearQueue, send, shell, upload } from './pr
23
23
  import { createTask, deleteTask, editTask, runTaskNow } from './scheduler.ts'
24
24
  import * as schema from './schema.ts'
25
25
  import { channelForDirectory, channelForSession, fork, resume, sessionCwd } from './sessions.ts'
26
- import { createSleep } from './sleeps.ts'
26
+ import { createSleep, sleepsWarm } from './sleeps.ts'
27
+ import { conversationTyping, workersTyping } from './thread-reducer.ts'
27
28
  import { worktreeName } from './worktrees.ts'
28
29
 
29
30
  type LockRoute<S extends z.ZodType, R> = {
@@ -82,6 +83,10 @@ const sendFields = {
82
83
  // '' asks for an automatic name.
83
84
  worktree: z.string({ error: 'worktree must be a name or an empty string for an automatic name' }).optional(),
84
85
  notifyOnly: z.boolean({ error: 'notifyOnly must be boolean' }).optional(),
86
+ // Seconds the CLI waits for the run (`--wait [seconds]`): a run that ends within them sends no coordinator note.
87
+ wait: z.number({ error: 'wait must be a number of seconds' }).positive({ error: 'wait must be a positive number of seconds' }).optional(),
88
+ // OPENCODE_SESSION_ID of the shell that ran `kimaki send`: a coordinator wakes when the runs it prompted end.
89
+ senderSessionId: text('senderSessionId').optional(),
85
90
  permissions: z.array(z.string(), { error: 'Permission rules must be strings' }).optional(),
86
91
  prompt: text('prompt'),
87
92
  files: z.array(z.object({ uri: z.string(), name: z.string() }), { error: 'files must contain uri and name strings' }).optional(),
@@ -265,6 +270,19 @@ async function snapshotHeap({ dataDir }: { dataDir: string }) {
265
270
  return { path: written }
266
271
  }
267
272
 
273
+ // The coordinator or a thread of its channel runs, from the thread views.
274
+ function coordinatorBusy(bot: Bot, sessionId: string): boolean {
275
+ const { sessionThreads, roots, threads } = bot.store.getState()
276
+ // A coordinator's conversation is its channel. A replaced one (/reset-coordinator), one of a
277
+ // channel with coordinator mode off, or a thread session is never busy.
278
+ const channelId = sessionThreads[sessionId]
279
+ if (!channelId || roots[channelId] !== sessionId || !isCoordinatorChannel(bot, channelId)) return false
280
+ const view = threads[channelId]
281
+ // An idle coordinator has no view after a bot restart; its threads do.
282
+ if (!view) return workersTyping({ threads, channelId })
283
+ return view.coordinator && conversationTyping({ threads, threadId: channelId })
284
+ }
285
+
268
286
  const loginField = z.string({ error: 'Login fields must be non-empty strings' }).min(1, { error: 'Login fields must be non-empty strings' }).optional()
269
287
 
270
288
  export const lockRoutes = {
@@ -297,6 +315,16 @@ export const lockRoutes = {
297
315
  }
298
316
  },
299
317
  }),
318
+ // The plugin keeps a session's prompt cache warm while its coordinator channel is busy
319
+ // or it sleeps (plugin/coordinator.ts).
320
+ 'session.warmup': route({
321
+ input: z.object({ sessionId: text('sessionId') }),
322
+ run: async (bot, { sessionId }) => {
323
+ const sleeping = await sleepsWarm(bot, sessionId)
324
+ if (sleeping instanceof Error) return sleeping
325
+ return { busy: coordinatorBusy(bot, sessionId), sleeping }
326
+ },
327
+ }),
300
328
  restart: route({
301
329
  input: z.object({}),
302
330
  run: async (bot) => {
@@ -371,6 +399,17 @@ export const lockRoutes = {
371
399
  return bot.features.agentUi.request({ ...input, sessionId, content: { buttons }, signal })
372
400
  },
373
401
  }),
402
+ // Labels of buttons each session showed and the user did not click yet (`kimaki session wait`).
403
+ 'session.buttons': route({
404
+ input: z.object({ sessionIds: z.array(text('sessionId'), { error: 'sessionIds must be an array' }) }),
405
+ run: async (bot, { sessionIds }) => {
406
+ const prompts = Object.values(bot.store.getState().threads).flatMap((view) => view.agentUi)
407
+ return Object.fromEntries(sessionIds.map((sessionId) => [
408
+ sessionId,
409
+ prompts.flatMap((prompt) => prompt.sessionId === sessionId ? (prompt.buttons ?? []).map((item) => item.label) : []),
410
+ ]))
411
+ },
412
+ }),
374
413
  'upload-request': route({
375
414
  input: z.object({
376
415
  ...agentUiFields,
@@ -493,9 +532,9 @@ export const lockRoutes = {
493
532
  run: async (bot, input) => {
494
533
  const target = localSession(bot, input)
495
534
  if (target instanceof Error) return target
496
- const thread = await fetchThread(bot, target.threadId)
535
+ const thread = await fetchConversation(bot, target.threadId)
497
536
  if (thread instanceof Error) return thread
498
- return sessionCwd(bot, { thread, directory: input.directory })
537
+ return sessionCwd(bot, { conversation: thread, directory: input.directory })
499
538
  },
500
539
  }),
501
540
  'session.title': route({
@@ -506,8 +545,10 @@ export const lockRoutes = {
506
545
  const title = input.text
507
546
  const renamed = await oc(bot, 'session.update', (client) => client.session.update({ sessionID: target.sessionId, title }))
508
547
  if (renamed instanceof Error) return renamed
509
- const thread = await fetchThread(bot, target.threadId)
548
+ const thread = await fetchConversation(bot, target.threadId)
510
549
  if (thread instanceof Error) return thread
550
+ // A coordinator channel keeps its project name.
551
+ if (!thread.isThread()) return { title }
511
552
  const updated = await thread.setName(title.slice(0, 100)).catch((cause) => new DiscordError({ operation: 'rename thread', cause }))
512
553
  if (updated instanceof Error) return updated
513
554
  return { title }
@@ -518,8 +559,9 @@ export const lockRoutes = {
518
559
  run: async (bot, input) => {
519
560
  const target = localSession(bot, input)
520
561
  if (target instanceof Error) return target
521
- const thread = await fetchThread(bot, target.threadId)
562
+ const thread = await fetchConversation(bot, target.threadId)
522
563
  if (thread instanceof Error) return thread
564
+ if (!thread.isThread()) return new ConfigError({ reason: 'A coordinator channel cannot be archived' })
523
565
  const archived = await thread.setArchived(true).catch((cause) => new DiscordError({ operation: 'archive thread', cause }))
524
566
  if (archived instanceof Error) return archived
525
567
  return { archived: true }
@@ -533,15 +575,27 @@ export const lockRoutes = {
533
575
  return abort(bot, { threadId: target.threadId })
534
576
  },
535
577
  }),
578
+ 'session.compact': route({
579
+ input: sessionInput({}),
580
+ run: async (bot, input) => {
581
+ const target = localSession(bot, input)
582
+ if (target instanceof Error) return target
583
+ // Queued, so an agent compacting its own session finishes its turn first.
584
+ const sessionID = input.sessionId ?? target.sessionId
585
+ const compaction = await oc(bot, 'session.compact', (client) => client.session.compact({ sessionID, delivery: 'queue' }))
586
+ if (compaction instanceof Error) return compaction
587
+ return { sessionId: sessionID, queued: true }
588
+ },
589
+ }),
536
590
  'session.fork': route({
537
591
  input: sessionInput({ before: text('before').optional(), name: text('name').optional() }),
538
592
  run: async (bot, input) => {
539
593
  const target = localSession(bot, input)
540
594
  if (target instanceof Error) return target
541
- const thread = await fetchThread(bot, target.threadId)
595
+ const thread = await fetchConversation(bot, target.threadId)
542
596
  if (thread instanceof Error) return thread
543
597
  // A child session ID forks that child, not the root.
544
- return fork(bot, { sourceThread: thread, sessionId: input.sessionId ?? target.sessionId, before: input.before, name: input.name, author: cliAuthor(bot) })
598
+ return fork(bot, { source: thread, sessionId: input.sessionId ?? target.sessionId, before: input.before, name: input.name, author: cliAuthor(bot) })
545
599
  },
546
600
  }),
547
601
  'session.command': route({
@@ -28,17 +28,33 @@ export { DEFAULT_LOCK_PORT }
28
28
  // Exit code that tells the supervisor of the root `kimaki` command (cli/bot.ts) to start the bot again.
29
29
  export const RESTART_EXIT_CODE = 75
30
30
 
31
- export async function installShim({ dataDir, command }: { dataDir: string; command: string }): Promise<ConfigError | void> {
31
+ export function shellQuote(arg: string): string {
32
+ return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`
33
+ }
34
+
35
+ // cmd.exe quoting for batch files: Windows paths cannot contain `"`, and `%` expands variables.
36
+ function cmdQuote(arg: string): string {
37
+ return `"${arg.replaceAll('%', '%%')}"`
38
+ }
39
+
40
+ // Writes `<dataDir>/bin/kimaki`, which runs `argv` (this Kimaki install). On Windows also
41
+ // `kimaki.cmd` for PowerShell and cmd.exe; Git Bash only finds the extensionless sh script.
42
+ export async function installShim({ dataDir, argv }: { dataDir: string; argv: readonly string[] }): Promise<ConfigError | void> {
32
43
  const directory = path.join(dataDir, 'bin')
33
44
  const created = await fs.promises.mkdir(directory, { recursive: true }).catch((cause) => new ConfigError({ reason: 'Cannot create Kimaki shim directory', cause }))
34
45
  if (created instanceof Error) return created
35
- const file = path.join(directory, process.platform === 'win32' ? 'kimaki.cmd' : 'kimaki')
46
+ const windows = process.platform === 'win32'
36
47
  // Bind the shim to its data dir through the env: not every command takes --data-dir.
37
- const script = process.platform === 'win32'
38
- ? `@echo off\r\nset "KIMAKI_DATA_DIR=${dataDir}"\r\n${command} %*\r\n`
39
- : `#!/bin/sh\nexport KIMAKI_DATA_DIR='${dataDir.replaceAll("'", "'\\''")}'\nexec ${command} "$@"\n`
40
- return fs.promises.writeFile(file, script, { mode: 0o700 }).then(() => fs.promises.chmod(file, 0o700))
41
- .catch((cause) => new ConfigError({ reason: 'Cannot install Kimaki command shim', cause }))
48
+ // MSYS sh runs `C:/...` paths; backslashes in a Windows path are not portable there.
49
+ const posixArgv = windows ? argv.map((arg) => arg.replaceAll('\\', '/')) : argv
50
+ const shims = [{ file: 'kimaki', script: `#!/bin/sh\nexport KIMAKI_DATA_DIR='${dataDir.replaceAll("'", "'\\''")}'\nexec ${posixArgv.map(shellQuote).join(' ')} "$@"\n` }]
51
+ if (windows) shims.push({ file: 'kimaki.cmd', script: `@echo off\r\nset "KIMAKI_DATA_DIR=${dataDir.replaceAll('%', '%%')}"\r\n${argv.map(cmdQuote).join(' ')} %*\r\n` })
52
+ for (const shim of shims) {
53
+ const file = path.join(directory, shim.file)
54
+ const written = await fs.promises.writeFile(file, shim.script, { mode: 0o700 }).then(() => fs.promises.chmod(file, 0o700))
55
+ .catch((cause) => new ConfigError({ reason: 'Cannot install Kimaki command shim', cause }))
56
+ if (written instanceof Error) return written
57
+ }
42
58
  }
43
59
 
44
60
  export type LockServer = {