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/effects.ts CHANGED
@@ -25,9 +25,10 @@ import {
25
25
  type SendableChannels,
26
26
  } from 'discord.js'
27
27
 
28
- import { formatFooter, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS, type UiMessage } from './format-parts.ts'
28
+ import { DISCORD_MEDIA_URL, embedFlags, formatFooter, SILENT_MESSAGE_FLAGS, type UiMessage } from './format-parts.ts'
29
29
  import { createLogger } from './logger.ts'
30
- import { segmentPayloads, type DiscordPayload } from './markdown/components.ts'
30
+ import { echoPayloads } from './queue.ts'
31
+ import { markdownPayloads, segmentPayloads, type DiscordPayload } from './markdown/components.ts'
31
32
  import { DISCORD_TEXT_LIMIT, renderMarkdown } from './markdown/render-markdown.ts'
32
33
  import { DiscordError } from './errors.ts'
33
34
  import type { Effect, UploadFile } from './thread-reducer.ts'
@@ -42,10 +43,45 @@ async function gitBranch(directory: string): Promise<string | null> {
42
43
  return result?.stdout.trim() || null
43
44
  }
44
45
 
45
- async function footerText(effect: Extract<Effect, { type: 'footer' }>): Promise<string> {
46
- const { directory, durationMs, contextPercent, model, agent } = effect
47
- const branch = await gitBranch(directory)
48
- return formatFooter({ folder: path.basename(directory), branch, durationMs, contextPercent, model, agent })
46
+ async function readDefaultBranch(directory: string): Promise<string | null> {
47
+ const git = (args: string[]) => execFileAsync('git', args, { cwd: directory, timeout: 5_000 }).then((r) => r.stdout.trim(), () => '')
48
+ const remoteHead = await git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
49
+ if (remoteHead) return remoteHead.replace(/^origin\//, '')
50
+ // No origin/HEAD (repo never cloned, or no remote): fall back to a local main or master.
51
+ const local = await git(['for-each-ref', '--format=%(refname:short)', 'refs/heads/main', 'refs/heads/master'])
52
+ return local.split('\n')[0] || null
53
+ }
54
+
55
+ // Directory -> default branch, read once per process: it almost never changes.
56
+ const defaultBranches = new Map<string, Promise<string | null>>()
57
+
58
+ function defaultBranch(directory: string): Promise<string | null> {
59
+ const cached = defaultBranches.get(directory)
60
+ if (cached) return cached
61
+ const lookup = readDefaultBranch(directory)
62
+ defaultBranches.set(directory, lookup)
63
+ return lookup
64
+ }
65
+
66
+ // Project folder and default agent of a channel, read when a footer is posted. null: not a project channel.
67
+ export type FooterDefaults = (channelId: string) => Promise<Error | null | { projectDirectory: string; defaultAgent: string | null }>
68
+
69
+ async function footerText({ effect, footerDefaults }: { effect: Extract<Effect, { type: 'footer' }>; footerDefaults: FooterDefaults }): Promise<string> {
70
+ const { channelId, directory, durationMs, contextPercent, model, agent } = effect
71
+ const [branch, mainBranch, defaults] = await Promise.all([gitBranch(directory), defaultBranch(directory), footerDefaults(channelId)])
72
+ if (defaults instanceof Error) logger.warn(`footer defaults of channel ${channelId} failed`, defaults)
73
+ const known = defaults instanceof Error ? null : defaults
74
+ return formatFooter({
75
+ directory,
76
+ projectDirectory: known?.projectDirectory ?? null,
77
+ branch,
78
+ defaultBranch: mainBranch,
79
+ durationMs,
80
+ contextPercent,
81
+ model,
82
+ agent,
83
+ defaultAgent: known?.defaultAgent ?? null,
84
+ })
49
85
  }
50
86
 
51
87
  // post() adds the notification flags to the payload's own flag.
@@ -119,6 +155,16 @@ function uploadMessage({ files, first, numbered }: { files: readonly UploadFile[
119
155
  return { files: attachments, components, flags: MessageFlags.IsComponentsV2 | SILENT_MESSAGE_FLAGS }
120
156
  }
121
157
 
158
+ // Components V2 messages come back with an empty `attachments` list: Discord
159
+ // resolves each attachment:// URL inside the components instead, in upload order.
160
+ function mediaUrls(components: readonly APIMessageTopLevelComponent[]): string[] {
161
+ return components.flatMap((component) => {
162
+ if (component.type === ComponentType.MediaGallery) return component.items.map((item) => item.media.url)
163
+ if (component.type === ComponentType.File) return [component.file.url]
164
+ return []
165
+ })
166
+ }
167
+
122
168
  // Effects that will never run: an upload still waits for its answer.
123
169
  function drop(effects: readonly Effect[]) {
124
170
  for (const effect of effects) {
@@ -128,7 +174,7 @@ function drop(effects: readonly Effect[]) {
128
174
 
129
175
  export type MarkViewed = (input: { sessionId: string; idle: number }) => Promise<Error | void>
130
176
 
131
- export function createEffectsRunner({ discord, markViewed }: { discord: Client; markViewed: MarkViewed }) {
177
+ export function createEffectsRunner({ discord, markViewed, footerDefaults }: { discord: Client; markViewed: MarkViewed; footerDefaults: FooterDefaults }) {
132
178
  const workers = new Map<string, ThreadWorker>()
133
179
  // After stop(): no more sends or typing, even from effects already queued.
134
180
  const lifecycle = { closed: false }
@@ -164,20 +210,23 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
164
210
  thread.typing = null
165
211
  }
166
212
 
167
- async function post({ threadId, options, notify }: { threadId: string; options: PostOptions; notify: boolean }) {
213
+ // `mentions: false` for user text (prompt echoes): it must never ping.
214
+ async function post({ threadId, options, notify, mentions = true }: { threadId: string; options: PostOptions; notify: boolean; mentions?: boolean }) {
168
215
  if (lifecycle.closed) return null
169
216
  const channel = await sendableChannel(threadId)
170
217
  if (!channel || lifecycle.closed) return null
171
- const flags = (options.flags ?? 0) | (notify ? NOTIFY_MESSAGE_FLAGS : SILENT_MESSAGE_FLAGS)
172
- const sent = await channel.send({ ...options, flags, allowedMentions: { parse: ['users'] } }).catch((e: Error) => e)
218
+ const flags = (options.flags ?? 0) | embedFlags(options.content) | (notify ? 0 : MessageFlags.SuppressNotifications)
219
+ // Replies never ping their author: the footer is what notifies.
220
+ const sent = await channel.send({ ...options, flags, allowedMentions: { parse: mentions ? ['users'] : [], repliedUser: false } }).catch((e: Error) => e)
173
221
  if (!(sent instanceof Error)) return sent.id
174
222
  logger.error(`send failed in ${threadId}`, sent)
175
223
  return null
176
224
  }
177
225
 
178
226
  // Unlike post(), the error goes back to the agent that uploaded.
179
- async function postFiles(threadId: string, files: readonly UploadFile[]): Promise<Error | null> {
227
+ async function postFiles(threadId: string, files: readonly UploadFile[]): Promise<Error | string[]> {
180
228
  let first = 1
229
+ const urls: string[] = []
181
230
  for (const batch of uploadBatches(files)) {
182
231
  if (lifecycle.closed) return new DiscordError({ operation: 'upload (bot stopped)' })
183
232
  const channel = await sendableChannel(threadId)
@@ -186,14 +235,15 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
186
235
  .send({ ...uploadMessage({ files: batch, first, numbered: files.length > 1 }), allowedMentions: { parse: [] } })
187
236
  .catch((cause: Error) => new DiscordError({ operation: `upload of ${batch.map((file) => file.name).join(', ')} (${cause.message})`, cause }))
188
237
  if (sent instanceof Error) return sent
238
+ urls.push(...mediaUrls(sent.components.map((component) => component.toJSON())))
189
239
  first += batch.length
190
240
  }
191
- return null
241
+ return urls
192
242
  }
193
243
 
194
244
  async function edit({ channel, messageId, message }: { channel: SendableChannels; messageId: string; message: UiMessage }) {
195
245
  const edited = await channel.messages
196
- .edit(messageId, { content: message.content, components: [...message.components] })
246
+ .edit(messageId, { content: message.content, components: [...message.components], flags: DISCORD_MEDIA_URL.test(message.content) ? [] : MessageFlags.SuppressEmbeds })
197
247
  .catch((e: Error) => e)
198
248
  if (edited instanceof Error) logger.warn(`edit of ${messageId} failed`, edited)
199
249
  }
@@ -228,15 +278,19 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
228
278
  return
229
279
  }
230
280
  if (effect.type === 'edit') {
231
- const ids = thread.prompts.get(effect.key)
281
+ const ids = thread.prompts.get(effect.key) ?? []
232
282
  thread.prompts.delete(effect.key)
233
- if (!ids || ids.length === 0) return
234
- const channel = await sendableChannel(threadId)
235
- if (!channel || lifecycle.closed) return
283
+ const channel = ids.length > 0 ? await sendableChannel(threadId) : null
284
+ if (lifecycle.closed) return
236
285
  for (const [index, messageId] of ids.entries()) {
237
286
  const message = effect.messages[index] ?? effect.messages[effect.messages.length - 1]
238
- if (message) await edit({ channel, messageId, message })
287
+ if (channel && message) await edit({ channel, messageId, message })
239
288
  }
289
+ if (effect.reply === undefined) return
290
+ // A key from before a restart: the reply still marks the moment, without a reference.
291
+ const replyTo = effect.replyTo ?? ids[0]
292
+ await post({ threadId, options: { content: effect.reply, ...(replyTo && { reply: { messageReference: replyTo, failIfNotExists: false } }) }, notify: false })
293
+ await refreshTyping(threadId)
240
294
  return
241
295
  }
242
296
  if (effect.type === 'show') {
@@ -251,6 +305,7 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
251
305
  ...(replyTo && { reply: { messageReference: replyTo, failIfNotExists: false } }),
252
306
  },
253
307
  notify: effect.notify,
308
+ mentions: !effect.echo,
254
309
  })
255
310
  if (id) ids.push(id)
256
311
  }
@@ -258,8 +313,9 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
258
313
  await refreshTyping(threadId)
259
314
  return
260
315
  }
261
- for (const payload of markdownMessages(effect)) {
262
- await post({ threadId, options: payload, notify: false })
316
+ for (const [index, payload] of markdownMessages(effect).entries()) {
317
+ const replyTo = index === 0 ? effect.replyTo : null
318
+ await post({ threadId, options: { ...payload, ...(replyTo && { reply: { messageReference: replyTo, failIfNotExists: false } }) }, notify: false })
263
319
  }
264
320
  await refreshTyping(threadId)
265
321
  }
@@ -270,13 +326,16 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
270
326
  const thread = worker(threadId)
271
327
  const generation = thread.generation
272
328
  const live = () => thread.generation === generation
273
- // A merged message notifies when any of its lines does.
274
- let lines: { text: string; notify: boolean } | null = null
329
+ // A merged message notifies when any of its lines does. Echoes and
330
+ // bot lines never merge, so an echo never pings and a bot line can.
331
+ // Lines with Discord media only merge with each other: their message
332
+ // shows embeds, so other links merged in would unfurl too.
333
+ let lines: { text: string; notify: boolean; mentions: boolean; media: boolean } | null = null
275
334
  const flush = async () => {
276
335
  const pending = lines
277
336
  lines = null
278
337
  if (pending === null || !live()) return
279
- await post({ threadId, options: { content: pending.text }, notify: pending.notify })
338
+ await post({ threadId, options: { content: pending.text }, notify: pending.notify, mentions: pending.mentions })
280
339
  await refreshTyping(threadId)
281
340
  }
282
341
  for (const [index, effect] of batch.entries()) {
@@ -287,15 +346,28 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
287
346
  await runOne(threadId, effect)
288
347
  continue
289
348
  }
290
- const text = effect.type === 'send' ? effect.text : await footerText(effect)
349
+ const text = effect.type === 'send' ? effect.text : await footerText({ effect, footerDefaults })
291
350
  const notify = effect.notify === true
351
+ const echo = effect.type === 'send' && effect.echo === true
352
+ const mentions = !echo
353
+ // A line over the limit (a long prompt echo) is split like model markdown.
354
+ if (text.length > DISCORD_TEXT_LIMIT) {
355
+ await flush()
356
+ for (const payload of echo ? echoPayloads(text) : markdownPayloads(text)) {
357
+ if (!live()) return drop(batch.slice(index + 1))
358
+ await post({ threadId, options: payload, notify, mentions })
359
+ }
360
+ await refreshTyping(threadId)
361
+ continue
362
+ }
363
+ const media = DISCORD_MEDIA_URL.test(text)
292
364
  const joined: string | null = lines === null ? null : `${lines.text}\n${text}`
293
- if (lines !== null && joined !== null && joined.length <= DISCORD_TEXT_LIMIT) {
294
- lines = { text: joined, notify: lines.notify || notify }
365
+ if (lines !== null && joined !== null && lines.mentions === mentions && lines.media === media && joined.length <= DISCORD_TEXT_LIMIT) {
366
+ lines = { text: joined, notify: lines.notify || notify, mentions, media }
295
367
  continue
296
368
  }
297
369
  await flush()
298
- lines = { text, notify }
370
+ lines = { text, notify, mentions, media }
299
371
  }
300
372
  await flush()
301
373
  }
package/src/event-loop.ts CHANGED
@@ -14,24 +14,26 @@
14
14
  // cold thread a bound thread without a view yet (bound in an earlier
15
15
  // bot run): read its project channel and directory once.
16
16
 
17
- import { DiscordAPIError, RESTJSONErrorCodes, type Client } from 'discord.js'
17
+ import { ChannelType, DiscordAPIError, RESTJSONErrorCodes, type Client } from 'discord.js'
18
18
 
19
19
  import type { Analytics } from './analytics.ts'
20
20
  import { verbosityFromV1, type KimakiDb } from './db.ts'
21
21
  import type { EffectsRunner } from './effects.ts'
22
22
  import { DbError, DiscordError, OpenCodeError } from './errors.ts'
23
- import { formatDuration } from './format-parts.ts'
24
23
  import { createLogger } from './logger.ts'
25
24
  import type { ConnectContext, OpenCodeClient, V2Event } from './opencode-server.ts'
26
- import type { EventRecorder } from './session-events.ts'
25
+ import { formatDuration, type EventRecorder } from './session-events.ts'
27
26
  import { queuedItems } from './queue.ts'
28
- import type { BotStore } from './store.ts'
27
+ import type { BotStore, State } from './store.ts'
29
28
  import {
29
+ conversationTyping,
30
30
  emptyView,
31
31
  eventSessionId,
32
32
  isKimakiEvent,
33
33
  reduce,
34
+ type Effect,
34
35
  type KimakiEvent,
36
+ type Prefs,
35
37
  type SessionSnapshot,
36
38
  type ThreadEvent,
37
39
  } from './thread-reducer.ts'
@@ -67,7 +69,6 @@ export function createEventLoop({
67
69
  effects,
68
70
  recorder,
69
71
  analytics,
70
- messageTool,
71
72
  onRunEnded,
72
73
  onTranscriptDue,
73
74
  }: {
@@ -77,8 +78,6 @@ export function createEventLoop({
77
78
  effects: EffectsRunner
78
79
  recorder: EventRecorder
79
80
  analytics: Analytics
80
- // `kimaki --message-tool`: plain text of root sessions stays hidden (Prefs.messageTool).
81
- messageTool: boolean
82
81
  // Every finished or failed run of a thread's root session (voice call notifications).
83
82
  onRunEnded: (run: RunEnded) => void
84
83
  // A root session stopped running (also after an interrupt): its transcript file is stale.
@@ -86,49 +85,66 @@ export function createEventLoop({
86
85
  }) {
87
86
  // The client of the current connection, set before hydration starts.
88
87
  const connection: { client: OpenCodeClient | null } = { client: null }
89
- // Context window sizes ("providerID/modelID" -> tokens) by directory.
88
+ // Model names and context window sizes ("providerID/modelID" -> info) by directory.
90
89
  // Cleared on connect; reloaded when the catalog changes (a /login adds a
91
- // provider). `limits` is null while the first load runs; a reload keeps the
92
- // old limits until it finishes. Failed loads are removed so the next call retries.
93
- const modelLimits = new Map<string, { load: Promise<void>; limits: Readonly<Record<string, number>> | null }>()
90
+ // provider). `models` is null while the first load runs; a reload keeps the
91
+ // old models until it finishes. Failed loads are removed so the next call retries.
92
+ const modelCatalog = new Map<string, { load: Promise<void>; models: Prefs['models'] | null }>()
94
93
  const coldThreads = new Map<string, Held>()
95
94
  const unknownSessions = new Map<string, Held>()
96
95
  const ignoredSessions = new Set<string>()
97
96
 
98
- // Resolves when the limits of `directory` are known (or failed to load).
97
+ // Resolves when the models of `directory` are known (or failed to load).
99
98
  // `reload`: read them again even when cached or loading.
100
- function loadModelLimits(directory: string, { reload = false } = {}): Promise<void> {
99
+ function loadModels(directory: string, { reload = false } = {}): Promise<void> {
101
100
  const client = connection.client
102
101
  if (!client) return Promise.resolve()
103
- const existing = modelLimits.get(directory)
102
+ const existing = modelCatalog.get(directory)
104
103
  if (existing && !reload) return existing.load
105
104
  const models = client.model.list({ location: { directory } }).catch((e) => new OpenCodeError({ operation: 'model.list', cause: e }))
106
105
  const load = models.then((result) => {
107
106
  // A reconnect or a newer reload replaced this load: its result is stale.
108
- if (modelLimits.get(directory)?.load !== load) return
107
+ if (modelCatalog.get(directory)?.load !== load) return
109
108
  if (result instanceof Error) {
110
- modelLimits.delete(directory)
109
+ modelCatalog.delete(directory)
111
110
  logger.warn(result)
112
111
  return
113
112
  }
114
- const limits = Object.fromEntries(result.data.map((model) => [`${model.providerID}/${model.id}`, model.limit.context]))
115
- modelLimits.set(directory, { load, limits })
113
+ const models = Object.fromEntries(result.data.map((model) => [`${model.providerID}/${model.id}`, { name: model.name, context: model.limit.context }]))
114
+ modelCatalog.set(directory, { load, models })
116
115
  })
117
- modelLimits.set(directory, { load, limits: existing?.limits ?? null })
116
+ modelCatalog.set(directory, { load, models: existing?.models ?? null })
118
117
  return load
119
118
  }
120
119
 
120
+ // Every change of thread views goes through here. The reducer's typing only
121
+ // sees its own view; typing follows conversationTyping instead, which also
122
+ // changes in the coordinator of the thread's channel.
123
+ function commit({ threadId, effects: produced = [], update }: { threadId: string; effects?: readonly Effect[]; update: (state: State) => Partial<State> }) {
124
+ const before = store.getState().threads
125
+ store.setState(update)
126
+ const after = store.getState().threads
127
+ const typing = (id: string): Effect[] => {
128
+ const on = conversationTyping({ threads: after, threadId: id })
129
+ return conversationTyping({ threads: before, threadId: id }) === on ? [] : [{ type: 'typing', on }]
130
+ }
131
+ // A removed view: effects.dispose() already stopped its typing.
132
+ if (after[threadId]) effects.run(threadId, [...typing(threadId), ...produced.filter((effect) => effect.type !== 'typing')])
133
+ const channelId = (after[threadId] ?? before[threadId])?.channelId
134
+ if (channelId && channelId !== threadId) effects.run(channelId, typing(channelId))
135
+ }
136
+
121
137
  // Folds one event into a loaded view.
122
138
  function fold(threadId: string, event: ThreadEvent) {
123
139
  const state = store.getState()
124
140
  const view = state.threads[threadId]
125
141
  if (!view) return
126
142
  const verbosity = state.verbosity[view.channelId] ?? verbosityFromV1(null)
127
- const contextLimits = modelLimits.get(view.directory)?.limits ?? {}
128
- const result = reduce({ view, event, prefs: { verbosity, contextLimits, messageTool } })
129
- if (result.view !== view) store.setState((current) => ({ threads: { ...current.threads, [threadId]: result.view } }))
130
- effects.run(threadId, result.effects)
131
- if (event.type === 'session.moved' && event.data.sessionID === view.sessionId) void loadModelLimits(event.data.location.directory)
143
+ const models = modelCatalog.get(view.directory)?.models ?? {}
144
+ const result = reduce({ view, event, prefs: { verbosity, models } })
145
+ if (result.view === view) effects.run(threadId, result.effects)
146
+ else commit({ threadId, effects: result.effects, update: (current) => ({ threads: { ...current.threads, [threadId]: result.view } }) })
147
+ if (event.type === 'session.moved' && event.data.sessionID === view.sessionId) void loadModels(event.data.location.directory)
132
148
  }
133
149
 
134
150
  // One line per run start and end of a root session, so the log shows what each thread did.
@@ -187,7 +203,8 @@ export function createEventLoop({
187
203
  if (!sessionId) return null
188
204
  const thread = await discord.channels.fetch(threadId).catch((e) => e instanceof DiscordAPIError && e.code === RESTJSONErrorCodes.UnknownChannel ? null : new DiscordError({ operation: `fetch thread ${threadId}`, cause: e }))
189
205
  if (thread instanceof Error) return thread
190
- const channelId = thread?.isThread() ? thread.parentId : null
206
+ // A coordinator channel is its own conversation.
207
+ const channelId = thread?.isThread() ? thread.parentId : thread?.type === ChannelType.GuildText ? thread.id : null
191
208
  if (!channelId) return null
192
209
  const project = await db.query.channel_directories.findFirst({ where: { channel_id: channelId } }).catch((e) => new DbError({ operation: 'read channel_directories', cause: e }))
193
210
  if (project instanceof Error) return project
@@ -196,7 +213,7 @@ export function createEventLoop({
196
213
  if (!client) return new OpenCodeError({ operation: 'load session directory while disconnected' })
197
214
  const info = await client.session.get({ sessionID: sessionId }).catch((cause) => new OpenCodeError({ operation: 'read session directory', cause }))
198
215
  if (info instanceof Error) return info
199
- await loadModelLimits(info.location.directory)
216
+ await loadModels(info.location.directory)
200
217
  return { channelId, directory: info.location.directory }
201
218
  })()
202
219
  entry.loading = false
@@ -214,8 +231,8 @@ export function createEventLoop({
214
231
  return
215
232
  }
216
233
  if (!store.getState().threads[threadId]) {
217
- const view = emptyView({ sessionId, channelId: loaded.channelId, directory: loaded.directory, isNew: false })
218
- store.setState((current) => ({ threads: { ...current.threads, [threadId]: view } }))
234
+ const view = emptyView({ sessionId, channelId: loaded.channelId, directory: loaded.directory, isNew: false, coordinator: threadId === loaded.channelId })
235
+ commit({ threadId, update: (current) => ({ threads: { ...current.threads, [threadId]: view } }) })
219
236
  }
220
237
  for (const event of entry.events) fold(threadId, event)
221
238
  }
@@ -321,7 +338,7 @@ export function createEventLoop({
321
338
  const failed = sessions.find((session) => session instanceof Error)
322
339
  if (failed instanceof Error) return failed
323
340
  const directory = info?.location.directory ?? null
324
- if (directory) await loadModelLimits(directory)
341
+ if (directory) await loadModels(directory)
325
342
  const event: ThreadSnapshot['event'] = {
326
343
  type: 'kimaki.snapshot',
327
344
  at,
@@ -351,19 +368,22 @@ export function createEventLoop({
351
368
  }
352
369
 
353
370
  return {
354
- // Bindings and channel verbosity from SQLite. Several bindings can share a
371
+ // Bindings, channel verbosity and coordinator mode from SQLite. Several bindings can share a
355
372
  // session after V1 /resume; the most recently updated one wins.
356
373
  async load(): Promise<DbError | void> {
357
- const [rows, verbosity] = await Promise.all([
374
+ const [rows, verbosity, coordinator] = await Promise.all([
358
375
  db.query.thread_sessions.findMany({ orderBy: { updated_at: 'asc' } }).catch((e) => new DbError({ operation: 'read thread_sessions', cause: e })),
359
376
  db.query.channel_verbosity.findMany().catch((e) => new DbError({ operation: 'read channel_verbosity', cause: e })),
377
+ db.query.channel_coordinator.findMany().catch((e) => new DbError({ operation: 'read channel_coordinator', cause: e })),
360
378
  ])
361
379
  if (rows instanceof Error) return rows
362
380
  if (verbosity instanceof Error) return verbosity
381
+ if (coordinator instanceof Error) return coordinator
363
382
  store.setState({
364
383
  roots: Object.fromEntries(rows.map((row) => [row.thread_id, row.session_id])),
365
384
  sessionThreads: Object.fromEntries(rows.map((row) => [row.session_id, row.thread_id])),
366
385
  verbosity: Object.fromEntries(verbosity.map((row) => [row.channel_id, verbosityFromV1(row.verbosity)])),
386
+ coordinator: Object.fromEntries(coordinator.map((row) => [row.channel_id, row.enabled === 1])),
367
387
  })
368
388
  },
369
389
 
@@ -388,26 +408,32 @@ export function createEventLoop({
388
408
  }): Promise<void> {
389
409
  ignoredSessions.delete(sessionId)
390
410
  coldThreads.delete(threadId)
391
- store.setState((current) => ({
392
- roots: { ...current.roots, [threadId]: sessionId },
393
- sessionThreads: { ...current.sessionThreads, [sessionId]: threadId },
394
- threads: { ...current.threads, [threadId]: emptyView({ sessionId, channelId, directory, isNew }) },
395
- }))
411
+ commit({
412
+ threadId,
413
+ update: (current) => ({
414
+ roots: { ...current.roots, [threadId]: sessionId },
415
+ sessionThreads: { ...current.sessionThreads, [sessionId]: threadId },
416
+ threads: { ...current.threads, [threadId]: emptyView({ sessionId, channelId, directory, isNew, coordinator: threadId === channelId }) },
417
+ }),
418
+ })
396
419
  // Same synchronous turn as the routing change: no live event can come first.
397
420
  for (const event of first) deliver(threadId, event)
398
421
  // Before the first prompt, so its footer can show the context percent.
399
- await loadModelLimits(directory)
422
+ await loadModels(directory)
400
423
  },
401
424
 
402
425
  // A thread loses its session (`/resume` moved the session to a new thread).
403
426
  unbind(threadId: string): void {
404
427
  coldThreads.delete(threadId)
405
428
  effects.dispose(threadId)
406
- store.setState((current) => {
407
- const { [threadId]: _root, ...roots } = current.roots
408
- const { [threadId]: _view, ...threads } = current.threads
409
- const sessionThreads = Object.fromEntries(Object.entries(current.sessionThreads).filter(([, mapped]) => mapped !== threadId))
410
- return { roots, threads, sessionThreads }
429
+ commit({
430
+ threadId,
431
+ update: (current) => {
432
+ const { [threadId]: _root, ...roots } = current.roots
433
+ const { [threadId]: _view, ...threads } = current.threads
434
+ const sessionThreads = Object.fromEntries(Object.entries(current.sessionThreads).filter(([, mapped]) => mapped !== threadId))
435
+ return { roots, threads, sessionThreads }
436
+ },
411
437
  })
412
438
  },
413
439
 
@@ -429,7 +455,7 @@ export function createEventLoop({
429
455
  async onConnect({ client, signal }: ConnectContext): Promise<OpenCodeError | void> {
430
456
  connection.client = client
431
457
  // A new connection may bring new providers, and old unrelated sessions are gone.
432
- modelLimits.clear()
458
+ modelCatalog.clear()
433
459
  ignoredSessions.clear()
434
460
  const result = await snapshots({ client, signal })
435
461
  if (result instanceof Error) return result
@@ -444,9 +470,9 @@ export function createEventLoop({
444
470
  onEvent(event: V2Event): void {
445
471
  if (CATALOG_EVENTS.has(event.type)) {
446
472
  // Credential events have no location: they can change every directory.
447
- const directories = event.location ? [event.location.directory] : [...modelLimits.keys()]
473
+ const directories = event.location ? [event.location.directory] : [...modelCatalog.keys()]
448
474
  for (const directory of directories) {
449
- if (modelLimits.has(directory)) void loadModelLimits(directory, { reload: true })
475
+ if (modelCatalog.has(directory)) void loadModels(directory, { reload: true })
450
476
  }
451
477
  return
452
478
  }
@@ -16,12 +16,24 @@ import {
16
16
  import { z } from 'zod'
17
17
 
18
18
  import type { Verbosity } from './db.ts'
19
+ import { formatDuration, formatTokens } from './session-events.ts'
19
20
 
20
- // Bot messages never show link previews. Only messages that need the user
21
+ // Bot messages hide link previews (session output: see embedFlags). Only messages that need the user
21
22
  // (errors, prompts, the end of a turn) skip SuppressNotifications.
22
23
  export const SILENT_MESSAGE_FLAGS = MessageFlags.SuppressEmbeds | MessageFlags.SuppressNotifications
23
24
  export const NOTIFY_MESSAGE_FLAGS = MessageFlags.SuppressEmbeds
24
25
 
26
+ // Discord CDN links (attachments from other threads, avatars, emojis) only
27
+ // show the media through their embed. Signed attachment URLs (?ex=&is=&hm=)
28
+ // are refreshed by the Discord client, so old ones still render.
29
+ // https://docs.discord.com/developers/reference#signed-attachment-cdn-urls
30
+ export const DISCORD_MEDIA_URL = /https:\/\/(?:cdn\.discordapp\.com|media\.discordapp\.net)\/\S/
31
+
32
+ // Session output hides link previews, except in messages with Discord media.
33
+ export function embedFlags(content: string | undefined): MessageFlags.SuppressEmbeds | 0 {
34
+ return content && DISCORD_MEDIA_URL.test(content) ? 0 : MessageFlags.SuppressEmbeds
35
+ }
36
+
25
37
  export function asSubtext(text: string): string {
26
38
  return `-# ${text}`
27
39
  }
@@ -108,45 +120,59 @@ export function selectedPage(value: string): number | null {
108
120
  return Number(value.slice(PAGE_PREFIX.length)) || 0
109
121
  }
110
122
 
111
- export function formatDuration(ms: number): string {
112
- if (ms < 1_000) return '<1s'
113
- const seconds = Math.floor(ms / 1_000)
114
- if (seconds < 60) return `${seconds}s`
115
- const minutes = Math.floor(seconds / 60)
116
- if (minutes < 60) return `${minutes}m ${seconds % 60}s`
117
- return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
118
- }
119
-
120
123
  export type ModelRef = { providerID: string; id: string }
121
124
 
122
- export function formatBanner({ model, agent }: { model: ModelRef & { variant?: string }; agent: string }): string {
125
+ const COORDINATOR_NOTICE = 'Coordinator mode is on: this channel is one session that starts and manages project threads for you. Use /new-session for a thread without it. Turn it off with /toggle-coordinator or start kimaki with `--coordinator false`.'
126
+
127
+ export function formatBanner({ model, agent, coordinator = false }: { model: ModelRef & { variant?: string }; agent: string; coordinator?: boolean }): string {
123
128
  // OpenCode reports `default` when no variant is selected.
124
129
  const variant = model.variant && model.variant !== 'default' ? ` ${model.variant}` : ''
125
- return asSubtext(`*using ${model.providerID}/${model.id}${variant} ⋅ ${agent}*`)
130
+ const banner = asSubtext(`*using ${model.providerID}/${model.id}${variant} ⋅ ${agent}*`)
131
+ return coordinator ? `${banner}\n${asSubtext(COORDINATOR_NOTICE)}` : banner
126
132
  }
127
133
 
134
+ // "Switched model to Claude Opus 5.5 (high)", for a root session switch after its banner.
135
+ export function formatModelSwitched({ name, variant }: { name: string; variant?: string }): string {
136
+ return asSubtext(`Switched model to ${name}${variant && variant !== 'default' ? ` (${variant})` : ''}`)
137
+ }
138
+
139
+ const agentLabel = (agent: string) => agent.charAt(0).toUpperCase() + agent.slice(1)
140
+
141
+ export function formatAgentSwitched({ agent, previous }: { agent: string; previous?: string }): string {
142
+ return asSubtext(`Switched agent ${previous ? `from ${agentLabel(previous)} ` : ''}to ${agentLabel(agent)}`)
143
+ }
144
+
145
+ // The folder, branch and agent only show when they differ from the defaults.
146
+ // `projectDirectory` / `defaultBranch` / `defaultAgent`: null when unknown, then they show.
128
147
  export function formatFooter({
129
- folder,
148
+ directory,
149
+ projectDirectory,
130
150
  branch,
151
+ defaultBranch,
131
152
  durationMs,
132
153
  contextPercent,
133
154
  model,
134
155
  agent,
156
+ defaultAgent,
135
157
  }: {
136
- folder: string
158
+ directory: string
159
+ projectDirectory: string | null
137
160
  branch: string | null
161
+ defaultBranch: string | null
138
162
  durationMs: number
139
163
  contextPercent: number | null
140
164
  model: ModelRef | null
141
165
  agent: string | null
166
+ defaultAgent: string | null
142
167
  }): string {
168
+ const inProject = projectDirectory !== null && path.resolve(directory) === path.resolve(projectDirectory)
143
169
  const parts = [
144
- folder,
145
- branch,
170
+ inProject ? null : path.basename(directory),
171
+ branch === defaultBranch ? null : branch,
146
172
  formatDuration(durationMs),
147
173
  contextPercent === null ? null : `${contextPercent}%`,
148
174
  model?.id ?? null,
149
- agent && agent !== 'build' ? agent : null,
175
+ agent && agent.toLowerCase() !== defaultAgent?.toLowerCase() ? agent : null,
150
176
  ].filter((part): part is string => Boolean(part))
151
177
  return asSubtext(`*${parts.join(' ⋅ ')}*`)
152
178
  }
@@ -162,12 +188,17 @@ export function formatError(message: string): string {
162
188
  // The provider error is the whole point: "retrying" alone says nothing.
163
189
  export function formatRetry({ attempt, delayMs, message }: { attempt: number; delayMs: number; message: string }): string {
164
190
  const seconds = Math.max(1, Math.ceil(delayMs / 1_000))
165
- return asSubtext(`⬦ retrying in ${seconds}s (attempt ${attempt}): ${inline(message, 200)}`)
191
+ return asSubtext(`retrying in ${seconds}s (attempt ${attempt}): ${inline(message, 200)}`)
192
+ }
193
+
194
+ // `tokens`: prompt size of the first step after the compaction, null when no step ran yet (`/compact`).
195
+ export function formatCompacted({ tokens, percent }: { tokens: number | null; percent: number | null }): string {
196
+ if (tokens === null) return asSubtext('context compacted')
197
+ return asSubtext(`context compacted to ${formatTokens(tokens)} tokens${percent === null ? '' : ` (${percent}%)`}`)
166
198
  }
167
199
 
168
200
  export function formatCacheMiss({ read, expected, idleMs }: { read: number; expected: number; idleMs: number }): string {
169
- const k = (tokens: number) => (tokens < 1_000 ? String(tokens) : `${Math.round(tokens / 1_000)}k`)
170
- return asSubtext(`⬦ prompt cache miss: ${k(read)} of ${k(expected)} tokens cached, ${formatDuration(idleMs)} after the last request`)
201
+ return asSubtext(`prompt cache miss: ${formatTokens(read)} of ${formatTokens(expected)} tokens cached, ${formatDuration(idleMs)} after the last request`)
171
202
  }
172
203
 
173
204
  // One line, no markdown control characters, so `_x_` and `*x*` stay intact.
@@ -350,7 +381,7 @@ export function formatToolFailed({ name, message, label }: { name: string; messa
350
381
  }
351
382
 
352
383
  export function formatSubagentFinished({ agent, description }: { agent: string; description: string }): string {
353
- return asSubtext(`⬦ ${inline(agent)} finished: ${inline(description)}`)
384
+ return asSubtext(`${inline(agent)} finished: ${inline(description)}`)
354
385
  }
355
386
 
356
387
  // `state` is OpenCode's job state; any value other than "completed" is shown.
@@ -370,7 +401,7 @@ export function formatShellFinished({
370
401
  state && state !== 'completed' ? inline(state) : null,
371
402
  exit !== null && exit !== 0 ? `exit ${exit}` : null,
372
403
  ].filter((note) => note !== null)
373
- return asSubtext(`⬦ ${prefix}background shell finished${description ? `: _${inline(description)}_` : ''}${notes.length > 0 ? ` (${notes.join(', ')})` : ''}`)
404
+ return asSubtext(`${prefix}background shell finished${description ? `: _${inline(description)}_` : ''}${notes.length > 0 ? ` (${notes.join(', ')})` : ''}`)
374
405
  }
375
406
 
376
407
  const SHELL_OUTPUT_LIMIT = 1_800