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
@@ -0,0 +1,168 @@
1
+ // Coordinator notes: when a thread session finishes a run, its coordinator learns
2
+ // about it, so the coordinator always knows what happened in its project.
3
+ //
4
+ // thread run ends ─▶ `kimaki send --wait` prompt, wait not over, target busy? ─▶ no note: the wait prints the answer
5
+ // ├─▶ run prompt sent by a coordinator (`sender`)? ─▶ note + wake that coordinator
6
+ // └─▶ else (user steered, routine), channel coordinator? ─▶ silent note (read at next prompt)
7
+ //
8
+ // Who sent the prompt is in its metadata (`sender`, prompt.ts), read from the
9
+ // run's messages: no state of its own. The note is a synthetic inbox item, like
10
+ // OpenCode's own subagent completion (core/src/session/subagent-completion.ts):
11
+ // `resume` decides if it wakes the session.
12
+
13
+ import { isCoordinatorChannel, oc, projectOf, readMarker, type Bot } from './bot.ts'
14
+ import type { RunEnded } from './event-loop.ts'
15
+ import { createLogger } from './logger.ts'
16
+ import { IDLE_REPLY } from './plugin/coordinator.ts'
17
+ import type { Sender } from './prompt.ts'
18
+ import { finalAnswer, formatDuration, formatTokens, sessionUsage, type Message } from './session-events.ts'
19
+ import { escapeAttribute } from './system-prompt.ts'
20
+
21
+ const logger = createLogger('COORDINATOR')
22
+
23
+ // A woken coordinator reads the whole answer; a silent note only says what happened.
24
+ const WAKE_ANSWER_CHARS = 2_000
25
+ const NOTE_ANSWER_CHARS = 400
26
+ const USER_PROMPT_CHARS = 1_000
27
+ const RUN_PAGE_MESSAGES = 30
28
+ // A run longer than this only loses uploads from its first steps.
29
+ const MAX_RUN_PAGES = 10
30
+
31
+ // The messages of the last run, oldest first: pages back until its user prompt.
32
+ async function lastRunMessages(bot: Bot, sessionId: string) {
33
+ const messages: Message[] = []
34
+ let cursor: string | undefined
35
+ for (let page = 0; page < MAX_RUN_PAGES; page++) {
36
+ const result = await oc(bot, 'message.list', (client) =>
37
+ client.message.list({ sessionID: sessionId, limit: RUN_PAGE_MESSAGES, ...(cursor ? { cursor } : { order: 'desc' as const }) }),
38
+ )
39
+ if (result instanceof Error) return result
40
+ messages.push(...result.data)
41
+ cursor = result.cursor.next ?? undefined
42
+ if (!cursor || result.data.some((message) => message.type === 'user')) break
43
+ }
44
+ return messages.reverse()
45
+ }
46
+
47
+ // Who wrote the run's prompt, from its metadata (prompt.ts): a sender
48
+ // (`kimaki send`, routine, sleep wake) or a person in Discord.
49
+ function runPrompt(messages: readonly Message[]): { sender: Sender | null; user: { username: string; text: string } | null } {
50
+ const prompt = messages.findLast((message) => message.type === 'user')
51
+ if (prompt?.type !== 'user') return { sender: null, user: null }
52
+ const sender: unknown = prompt.metadata?.['sender']
53
+ if (sender && typeof sender === 'object') {
54
+ const sessionId: unknown = Reflect.get(sender, 'sessionId')
55
+ const waitUntil: unknown = Reflect.get(sender, 'waitUntil')
56
+ return { sender: { ...(typeof sessionId === 'string' && { sessionId }), ...(typeof waitUntil === 'number' && { waitUntil }) }, user: null }
57
+ }
58
+ const discord: unknown = prompt.metadata?.['discord']
59
+ const username: unknown = discord && typeof discord === 'object' ? Reflect.get(discord, 'username') : null
60
+ return { sender: null, user: typeof username === 'string' ? { username, text: prompt.text } : null }
61
+ }
62
+
63
+ // The coordinator a session belongs to: the root of its conversation when that is
64
+ // a coordinator channel. The sender may be a subagent of that coordinator.
65
+ async function coordinatorOf(bot: Bot, sessionId: string): Promise<string | null> {
66
+ const { sessionThreads, roots } = bot.store.getState()
67
+ const conversation = sessionThreads[sessionId]
68
+ if (!conversation || !(await holdsCoordinator(bot, conversation))) return null
69
+ return roots[conversation] ?? null
70
+ }
71
+
72
+ function cut(text: string, max: number): string {
73
+ return text.length <= max ? text : `${text.slice(0, max)}…`
74
+ }
75
+
76
+ // A conversation that is a project channel in coordinator mode holds a coordinator.
77
+ async function holdsCoordinator(bot: Bot, conversationId: string): Promise<boolean> {
78
+ if (!isCoordinatorChannel(bot, conversationId)) return false
79
+ const project = await projectOf(bot, conversationId)
80
+ return !(project instanceof Error) && project !== null
81
+ }
82
+
83
+ // The project channel of a session thread, from its view or from Discord.
84
+ async function channelOfThread(bot: Bot, threadId: string): Promise<string | null> {
85
+ const view = bot.store.getState().threads[threadId]
86
+ if (view) return view.channelId
87
+ const channel = await bot.discord.channels.fetch(threadId).catch(() => null)
88
+ return channel?.isThread() ? channel.parentId : null
89
+ }
90
+
91
+ // The run's prompt came from `kimaki send --wait`, the run ended before the wait
92
+ // ran out and the target still runs: it is waiting in that shell call, which
93
+ // prints the answer. A target that is idle lost its wait (shell timeout,
94
+ // interrupt), so it gets the note.
95
+ async function isWaiting(bot: Bot, target: string): Promise<boolean> {
96
+ const active = await oc(bot, 'session.active', (client) => client.session.active())
97
+ // On a failed check the note goes out: a duplicate is better than a lost result.
98
+ if (active instanceof Error) {
99
+ logger.warn(`wait check of ${target}`, active)
100
+ return false
101
+ }
102
+ return target in active
103
+ }
104
+
105
+ async function noteRunEnded(bot: Bot, run: RunEnded): Promise<void> {
106
+ const channelId = await channelOfThread(bot, run.threadId)
107
+ // The coordinator's own runs, and threads outside any project channel.
108
+ if (!channelId || channelId === run.threadId) return
109
+ const [info, messages] = await Promise.all([
110
+ oc(bot, 'session.get', (client) => client.session.get({ sessionID: run.sessionId })),
111
+ lastRunMessages(bot, run.sessionId),
112
+ ])
113
+ if (info instanceof Error) return logger.warn(`note of ${run.sessionId}`, info)
114
+ if (messages instanceof Error) return logger.warn(`answer of ${run.sessionId}`, messages)
115
+ const marker = readMarker(info.metadata)
116
+ const { sender, user } = runPrompt(messages)
117
+ // TODO: also note sessions on other machines (remote `kimaki send`, OpenCode workspaces).
118
+ // Their bot cannot reach this OpenCode server: relay the note through a Discord envelope
119
+ // in the coordinator channel, like remote-send.ts does for prompts.
120
+ // Only a run a coordinator prompted wakes it. The user steering the thread, routines and
121
+ // other senders leave a silent note in the channel's coordinator.
122
+ const sentBy = sender?.sessionId ? await coordinatorOf(bot, sender.sessionId) : null
123
+ const wake = sentBy !== null
124
+ const target = sentBy ?? (isCoordinatorChannel(bot, channelId) ? bot.store.getState().roots[channelId] : undefined)
125
+ if (!target || target === run.sessionId) return
126
+ if (sender?.waitUntil !== undefined && Date.now() < sender.waitUntil && (await isWaiting(bot, target))) return
127
+ const answer = run.error ?? finalAnswer(messages) ?? 'Finished without a text answer.'
128
+ const state = run.error ? 'error' : 'completed'
129
+ const title = info.title || 'Untitled'
130
+ const routine = marker?.task ? ` routine="#${marker.task.id}"` : ''
131
+ // Context size and run length, so the coordinator knows when a follow-up would rewrite a big cache.
132
+ const usage = sessionUsage(messages)
133
+ const stats = usage ? ` context="${formatTokens(usage.contextTokens)} tokens"${usage.runMs === null ? '' : ` duration="${formatDuration(usage.runMs)}"`}` : ''
134
+ const text = [
135
+ `<session-finished sessionID="${run.sessionId}" threadID="${run.threadId}" state="${state}" title="${escapeAttribute(title)}"${routine}${stats} wake="${wake}">`,
136
+ // The user steered this thread directly: the coordinator must know what they asked.
137
+ ...(user ? [`<user-prompt username="${escapeAttribute(user.username)}">\n${cut(user.text, USER_PROMPT_CHARS)}\n</user-prompt>`] : []),
138
+ cut(answer, wake ? WAKE_ANSWER_CHARS : NOTE_ANSWER_CHARS),
139
+ `More: kimaki session read ${run.sessionId}`,
140
+ ...(wake ? [`The user sees nothing else about this. When it is work they asked for, a failure or a decision they must make, tell them the outcome in a few plain sentences, with the thread link <#${run.threadId}> once. Otherwise reply exactly ${IDLE_REPLY}.`] : []),
141
+ '</session-finished>',
142
+ ].join('\n')
143
+ const sent = await oc(bot, 'session.synthetic', (client) =>
144
+ client.session.synthetic({
145
+ sessionID: target,
146
+ text,
147
+ description: `${title} finished`,
148
+ metadata: { source: 'kimaki-session', sessionID: run.sessionId, threadID: run.threadId, title, state, wake },
149
+ resume: wake,
150
+ }),
151
+ )
152
+ if (sent instanceof Error) return logger.warn(`note of ${run.sessionId} to ${target}`, sent)
153
+ logger.log(`${wake ? 'woke' : 'noted'} coordinator ${target}: ${run.sessionId} ${state}`)
154
+ }
155
+
156
+ export function createCoordinatorNotes() {
157
+ const registered: { bot: Bot | null } = { bot: null }
158
+ return {
159
+ register(bot: Bot) {
160
+ registered.bot = bot
161
+ },
162
+ runEnded(run: RunEnded) {
163
+ const bot = registered.bot
164
+ // No channel in coordinator mode: no note to send, so no OpenCode reads.
165
+ if (bot && (bot.coordinatorDefault || Object.values(bot.store.getState().coordinator).includes(true))) void noteRunEnded(bot, run)
166
+ },
167
+ }
168
+ }
package/src/db.test.ts CHANGED
@@ -89,6 +89,7 @@ test('fresh install gets kimaki.db with the kimaki tables; subcommands never cre
89
89
  "bot_settings",
90
90
  "bot_tokens",
91
91
  "channel_agents",
92
+ "channel_coordinator",
92
93
  "channel_directories",
93
94
  "channel_mention_mode",
94
95
  "channel_models",
@@ -0,0 +1,56 @@
1
+ import { render } from 'pele'
2
+ import { expect, test } from 'vitest'
3
+
4
+ import { computeCustomProperties, renderDiagram, withColorClasses } from './diagram.ts'
5
+
6
+ const NESTED = `flowchart LR
7
+ subgraph outer[Outer]
8
+ subgraph inner[Inner]
9
+ a[A]:::blue --> b[(B)]
10
+ end
11
+ end
12
+ b -->|label| c{C}
13
+ classDef blue fill:#0b1f38,stroke:#1d68c5`
14
+
15
+ test('computes inherited custom properties, fallbacks and color-mix', () => {
16
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" style="--_a:var(--theme-a,#ffffff);--_b:var(--missing,#000000);width:10px"><g style="--_a:#ff0000"><rect fill="var(--_a)" stroke="color-mix(in srgb,var(--_a) 50%,var(--_b))"/></g><rect fill="var(--_a)" style="rx:var(--_r, 4px)"/></svg>`
17
+ expect(computeCustomProperties(svg, new Map([['--theme-a', '#00ff00']]))).toMatchInlineSnapshot(`"<svg xmlns="http://www.w3.org/2000/svg" style="width:10px"><g><rect fill="#ff0000" stroke="#800000"/></g><rect fill="#00ff00" rx="4px"/></svg>"`)
18
+ })
19
+
20
+ test('leaves no var() or color-mix() in pele output', () => {
21
+ const svg = computeCustomProperties(render(NESTED, { padding: 24 }).svg)
22
+ expect(svg).not.toContain('var(')
23
+ expect(svg).not.toContain('color-mix')
24
+ expect(svg).toContain('fill="#ffffff"')
25
+ })
26
+
27
+ test('renders a png and reports syntax errors with position', () => {
28
+ const png = renderDiagram(NESTED)
29
+ if (png instanceof Error) throw png
30
+ expect(png.subarray(1, 4).toString()).toBe('PNG')
31
+ const dark = renderDiagram(NESTED, { theme: 'dark' })
32
+ if (dark instanceof Error) throw dark
33
+ expect(dark.equals(png)).toBe(false)
34
+
35
+ const error = renderDiagram('flowchart LR\n a --> \n b[unclosed')
36
+ expect(error instanceof Error ? error.message : 'no error').toMatchInlineSnapshot(`
37
+ "Parse error on line 11:
38
+
39
+ ^
40
+ Expecting 'SQE', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got '$end'"
41
+ `)
42
+ })
43
+
44
+ test('adds built-in color classes the source does not define', () => {
45
+ expect(withColorClasses('flowchart LR\n a:::blue --> b:::teal\n classDef teal,red fill:#000')).toMatchInlineSnapshot(`
46
+ "flowchart LR
47
+ a:::blue --> b:::teal
48
+ classDef teal,red fill:#000
49
+ classDef blue fill:#edf5fb,stroke:#a0c3e2,color:#141413
50
+ classDef green fill:#eef6f0,stroke:#a7c8af,color:#141413
51
+ classDef purple fill:#f3effa,stroke:#bfb1de,color:#141413
52
+ classDef pink fill:#fbeef3,stroke:#e0abbf,color:#141413
53
+ classDef amber fill:#fbf5e8,stroke:#e2c893,color:#141413"
54
+ `)
55
+ expect(withColorClasses('sequenceDiagram\n A->>B: hi')).toBe('sequenceDiagram\n A->>B: hi')
56
+ })
package/src/diagram.ts ADDED
@@ -0,0 +1,243 @@
1
+ // `kimaki diagram`: renders Mermaid source to a PNG for Discord, which shows
2
+ // SVG attachments as code, not as images. pele (https://pele.run) lays out
3
+ // the diagram as SVG in pure JS; resvg rasterizes it with the bundled Geist fonts.
4
+ // Loaded lazily by the CLI command, so the static imports cost nothing at startup.
5
+
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { Resvg } from '@resvg/resvg-js'
10
+ import * as errore from 'errore'
11
+ import { DomUtils, parseDocument } from 'htmlparser2'
12
+ import { detectType, render } from 'pele'
13
+ import postcss from 'postcss'
14
+ import valueParser, { type Node as ValueNode } from 'postcss-value-parser'
15
+
16
+ const FONTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'fonts')
17
+ const SCALE = 2
18
+ const PADDING = 24
19
+ // Inherited by every shape without its own width, edges included.
20
+ const STROKE_WIDTH = 1.5
21
+ // Share of the hue in a color class border; the rest is its fill, for soft borders.
22
+ const BORDER_HUE = 0.5
23
+ // Discord shrinks long images until labels are unreadable; a square one reads best.
24
+ // The compact candidate asks pele for the narrowest layout: wrapped labels and `LR`
25
+ // turned into `TB` (https://pele.run/api#narrow-screens).
26
+ const SQUARE_CANDIDATES = [{ autoDirection: false }, { maxWidth: 1, autoDirection: true, directionBreakpoint: Infinity }]
27
+
28
+ type ColorName = 'blue' | 'green' | 'purple' | 'pink' | 'amber' | 'red' | 'teal'
29
+ type Palette = { readonly [name in ColorName]: { fill: string; stroke: string } }
30
+
31
+ // Category hues, built in as classDefs so diagrams only write `node:::blue`.
32
+ // Light: the claude-style palette of the diagrams-and-charts skill. Dark: Vercel/Next.js docs.
33
+ const LIGHT_COLORS: Palette = {
34
+ blue: { fill: '#edf5fb', stroke: '#5290c8' },
35
+ green: { fill: '#eef6f0', stroke: '#5f9a6d' },
36
+ purple: { fill: '#f3effa', stroke: '#8a72c2' },
37
+ pink: { fill: '#fbeef3', stroke: '#c4688a' },
38
+ amber: { fill: '#fbf5e8', stroke: '#c99a3e' },
39
+ red: { fill: '#fbeeee', stroke: '#c8524f' },
40
+ teal: { fill: '#ebf6f4', stroke: '#3f9a8f' },
41
+ }
42
+
43
+ const DARK_COLORS: Palette = {
44
+ blue: { fill: '#0b1f38', stroke: '#1d68c5' },
45
+ green: { fill: '#1a2c1e', stroke: '#49a45f' },
46
+ purple: { fill: '#251931', stroke: '#8955a7' },
47
+ pink: { fill: '#371523', stroke: '#c14c70' },
48
+ amber: { fill: '#4c3d20', stroke: '#f2b342' },
49
+ red: { fill: '#3c1618', stroke: '#e5484d' },
50
+ teal: { fill: '#0d2d2a', stroke: '#12a594' },
51
+ }
52
+
53
+ export type DiagramTheme = 'light' | 'dark'
54
+
55
+ type Theme = { colors: Palette; vars: ReadonlyMap<string, string> }
56
+
57
+ // pele's CSS variables (https://pele.run/theming).
58
+ function theme({ colors, base }: { colors: Palette; base: Record<string, string> }): Theme {
59
+ const vars = new Map(Object.entries({
60
+ ...base,
61
+ '--pele-radius': '10px',
62
+ '--pele-font': 'Geist, sans-serif',
63
+ '--pele-font-mono': 'Geist Mono, monospace',
64
+ '--pele-series-1': colors.blue.stroke,
65
+ '--pele-series-2': colors.amber.stroke,
66
+ '--pele-series-3': colors.green.stroke,
67
+ '--pele-series-4': colors.pink.stroke,
68
+ '--pele-series-5': colors.purple.stroke,
69
+ '--pele-series-6': base['--pele-accent']!,
70
+ '--pele-series-7': colors.red.stroke,
71
+ '--pele-series-8': colors.teal.stroke,
72
+ }))
73
+ return { colors, vars }
74
+ }
75
+
76
+ const THEMES: { readonly [name in DiagramTheme]: Theme } = {
77
+ light: theme({
78
+ colors: LIGHT_COLORS,
79
+ base: {
80
+ '--pele-bg': '#faf9f5',
81
+ '--pele-fg': '#141413',
82
+ '--pele-muted': '#72736e',
83
+ '--pele-line': '#8c8b85',
84
+ '--pele-surface': '#ffffff',
85
+ '--pele-surface-alt': '#efeee5',
86
+ '--pele-border': '#d1cfc5',
87
+ '--pele-accent': '#d97757',
88
+ '--pele-critical': LIGHT_COLORS.pink.stroke,
89
+ '--pele-error': LIGHT_COLORS.red.stroke,
90
+ },
91
+ }),
92
+ dark: theme({
93
+ colors: DARK_COLORS,
94
+ base: {
95
+ '--pele-bg': '#0a0a0a',
96
+ '--pele-fg': '#ededed',
97
+ '--pele-muted': '#8f8f8f',
98
+ '--pele-line': '#878787',
99
+ '--pele-surface': '#171717',
100
+ '--pele-surface-alt': '#232323',
101
+ '--pele-border': '#3d3d3d',
102
+ '--pele-accent': '#4a8fe7',
103
+ '--pele-critical': DARK_COLORS.pink.stroke,
104
+ '--pele-error': DARK_COLORS.red.stroke,
105
+ },
106
+ }),
107
+ }
108
+
109
+ export class DiagramError extends errore.createTaggedError({
110
+ name: 'DiagramError',
111
+ message: '$reason',
112
+ }) {}
113
+
114
+ // resvg and librsvg do not support CSS custom properties, which pele uses for
115
+ // every color. This does the browser's job on the parsed SVG: custom properties
116
+ // inherit down the element tree, and every var() and color-mix() in attributes
117
+ // and inline styles is computed to a plain value. resvg also ignores rx/ry in
118
+ // style (pele writes rx:var(--_r)), so they move to attributes.
119
+ // TODO: drop this once resvg resolves var(). https://github.com/linebender/resvg/issues/821
120
+ export function computeCustomProperties(svg: string, vars: ReadonlyMap<string, string> = THEMES.light.vars): string {
121
+ const document = parseDocument(svg, { xmlMode: true })
122
+ for (const child of document.children) visit(child, vars)
123
+ return DomUtils.getOuterHTML(document, { xmlMode: true })
124
+ }
125
+
126
+ type DomNode = ReturnType<typeof parseDocument>['children'][number]
127
+
128
+ function visit(node: DomNode, inherited: ReadonlyMap<string, string>) {
129
+ if (!DomUtils.isTag(node)) return
130
+ const vars = new Map(inherited)
131
+ const { style, ...attributes } = node.attribs
132
+ if (style) {
133
+ const declarations = postcss.parse(style).nodes.filter((child) => child.type === 'decl')
134
+ // Custom properties first: they apply to this element's own declarations too.
135
+ for (const decl of declarations) if (decl.prop.startsWith('--')) vars.set(decl.prop, computeValue(decl.value, vars))
136
+ for (const decl of declarations) if (decl.prop === 'rx' || decl.prop === 'ry') node.attribs[decl.prop] = computeValue(decl.value, vars)
137
+ const kept = declarations
138
+ .filter((decl) => !decl.prop.startsWith('--') && decl.prop !== 'rx' && decl.prop !== 'ry')
139
+ .map((decl) => `${decl.prop}:${computeValue(decl.value, vars)}`)
140
+ if (kept.length > 0) node.attribs.style = kept.join(';')
141
+ else delete node.attribs.style
142
+ }
143
+ for (const [name, value] of Object.entries(attributes)) node.attribs[name] = computeValue(value, vars)
144
+ for (const child of node.children) visit(child, vars)
145
+ }
146
+
147
+ function computeValue(value: string, vars: ReadonlyMap<string, string>): string {
148
+ if (!value.includes('var(') && !value.includes('color-mix(')) return value
149
+ const parsed = valueParser(value)
150
+ computeNodes(parsed.nodes, vars)
151
+ return parsed.toString()
152
+ }
153
+
154
+ // Replaces var() and color-mix() function nodes in place with word nodes.
155
+ function computeNodes(nodes: ValueNode[], vars: ReadonlyMap<string, string>) {
156
+ for (const node of nodes) {
157
+ if (node.type !== 'function') continue
158
+ computeNodes(node.nodes, vars)
159
+ if (node.value === 'var') {
160
+ const [name, ...rest] = splitArgs(node.nodes)
161
+ const fallback = rest.length > 0 ? rest.join(',') : undefined
162
+ replaceWithWord(node, vars.get(name ?? '') ?? fallback ?? 'initial')
163
+ } else if (node.value === 'color-mix') {
164
+ const mixed = colorMix(splitArgs(node.nodes))
165
+ if (mixed) replaceWithWord(node, mixed)
166
+ }
167
+ }
168
+ }
169
+
170
+ function splitArgs(nodes: ValueNode[]): string[] {
171
+ const args: ValueNode[][] = [[]]
172
+ for (const node of nodes) {
173
+ if (node.type === 'div' && node.value === ',') args.push([])
174
+ else args.at(-1)!.push(node)
175
+ }
176
+ return args.map((arg) => valueParser.stringify(arg).trim())
177
+ }
178
+
179
+ function replaceWithWord(node: ValueNode, value: string) {
180
+ Object.assign(node, { type: 'word', value, nodes: undefined })
181
+ }
182
+
183
+ // color-mix(in srgb, <hex> [<pct>], <hex> [<pct>]): the only form pele emits.
184
+ function colorMix([space, first, second]: string[]): string | undefined {
185
+ if (space !== 'in srgb' || !first || !second) return undefined
186
+ const [colorA, percentA] = first.split(/\s+/)
187
+ const [colorB, percentB] = second.split(/\s+/)
188
+ const weightA = percentA ? parseFloat(percentA) / 100 : percentB ? 1 - parseFloat(percentB) / 100 : 0.5
189
+ return mixHex({ a: colorA, b: colorB, weightA })
190
+ }
191
+
192
+ function mixHex({ a, b, weightA }: { a: string | undefined; b: string | undefined; weightA: number }): string | undefined {
193
+ const channelsA = hexChannels(a)
194
+ const channelsB = hexChannels(b)
195
+ if (!channelsA || !channelsB) return undefined
196
+ return `#${channelsA.map((channel, i) => Math.round(channel * weightA + channelsB[i]! * (1 - weightA)).toString(16).padStart(2, '0')).join('')}`
197
+ }
198
+
199
+ function hexChannels(color: string | undefined): number[] | undefined {
200
+ if (!color || !/^#[0-9a-f]{6}$/i.test(color)) return undefined
201
+ return [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16))
202
+ }
203
+
204
+ // Renders Mermaid source to PNG bytes. pele syntax errors already include the line and a pointer.
205
+ // Diagram types whose grammar accepts classDef lines.
206
+ const CLASS_DEF_TYPES = new Set(['flowchart', 'swimlane', 'state', 'class', 'er', 'requirement'])
207
+
208
+ // Appends a classDef per color the source does not define itself.
209
+ export function withColorClasses(source: string, theme: DiagramTheme = 'light'): string {
210
+ const type = detectType(source)
211
+ if (!type || !CLASS_DEF_TYPES.has(type)) return source
212
+ const { colors, vars } = THEMES[theme]
213
+ const defined = new Set(source.split('\n').flatMap((line) => line.trim().match(/^classDef\s+(\S+)/)?.[1]?.split(',') ?? []))
214
+ const lines = Object.entries(colors)
215
+ .filter(([name]) => !defined.has(name))
216
+ .map(([name, { fill, stroke }]) => ` classDef ${name} fill:${fill},stroke:${mixHex({ a: stroke, b: fill, weightA: BORDER_HUE })},color:${vars.get('--pele-fg')}`)
217
+ return [source.trimEnd(), ...lines].join('\n')
218
+ }
219
+
220
+ export function renderDiagram(source: string, { theme = 'light' }: { theme?: DiagramTheme } = {}): DiagramError | Buffer {
221
+ const { vars } = THEMES[theme]
222
+ const colored = withColorClasses(source, theme)
223
+ const renders = errore.try(
224
+ () => SQUARE_CANDIDATES.map((layout) => render(colored, { fontFamily: 'Geist', fontFamilyMono: 'Geist Mono', padding: PADDING, responsive: false, links: false, images: false, ...layout })),
225
+ (error) => new DiagramError({ reason: error instanceof Error ? error.message : String(error) }),
226
+ )
227
+ if (renders instanceof Error) return renders
228
+ const squareness = ({ width, height }: { width: number; height: number }) => Math.abs(Math.log(width / height))
229
+ const result = renders.reduce((best, candidate) => (squareness(candidate) < squareness(best) ? candidate : best))
230
+ const fontFiles = fs.readdirSync(FONTS_DIR).filter((file) => file.endsWith('.ttf')).map((file) => path.join(FONTS_DIR, file))
231
+ return errore.try(
232
+ () => {
233
+ const svg = result.svg.replace('<svg ', `<svg stroke-width="${STROKE_WIDTH}" `)
234
+ const resvg = new Resvg(computeCustomProperties(svg, vars), {
235
+ background: vars.get('--pele-bg'),
236
+ fitTo: { mode: 'zoom', value: SCALE },
237
+ font: { loadSystemFonts: false, fontFiles, defaultFontFamily: 'Geist', sansSerifFamily: 'Geist', monospaceFamily: 'Geist Mono' },
238
+ })
239
+ return resvg.render().asPng()
240
+ },
241
+ (error) => new DiagramError({ reason: `Rasterizing failed: ${error instanceof Error ? error.message : String(error)}`, cause: error }),
242
+ )
243
+ }
package/src/duration.ts CHANGED
@@ -1,18 +1,26 @@
1
- // Relative durations like `30m` or `2h` (`session wait --timeout`, `kimaki sleep --duration`).
1
+ // Relative durations like `30m`, `2h` or `1 hour` (`session wait --timeout`, `kimaki sleep --duration`).
2
2
  // Dependency-light so the CLI can use it without loading bot modules.
3
3
 
4
4
  import { ConfigError } from './errors.ts'
5
5
 
6
- const DURATION = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i
7
- const DURATION_MS = new Map(Object.entries({ ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }))
6
+ const DURATION = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i
7
+ const UNITS: ReadonlyArray<[number, string[]]> = [
8
+ [1, ['ms', 'milli', 'millis']],
9
+ [1_000, ['s', 'second', 'seconds']],
10
+ [60_000, ['m', 'minute', 'minutes']],
11
+ [3_600_000, ['h', 'hour', 'hours']],
12
+ [86_400_000, ['d', 'day', 'days']],
13
+ ]
14
+ const DURATION_MS = new Map(UNITS.flatMap(([ms, names]) => names.map((name) => [name, ms] as const)))
8
15
 
9
16
  // Milliseconds of a positive duration: a number plus ms, s, m, h, or d.
10
17
  // `label` names the input in errors, for example a flag like `--duration`.
11
18
  export function parseDuration(text: string, label = 'duration'): ConfigError | number {
12
19
  const trimmed = text.trim()
13
20
  const match = DURATION.exec(trimmed)
14
- if (!match) return new ConfigError({ reason: `Invalid ${label} "${trimmed}". Use a number plus ms, s, m, h, or d (example: 2h)` })
15
- const amount = Number(match[1]) * DURATION_MS.get(match[2]!.toLowerCase())!
21
+ const unit = match ? DURATION_MS.get(match[2]!.toLowerCase()) : undefined
22
+ if (!match || !unit) return new ConfigError({ reason: `Invalid ${label} "${trimmed}". Use a number plus ms, s, m, h, or d (example: 2h)` })
23
+ const amount = Number(match[1]) * unit
16
24
  if (!(amount > 0)) return new ConfigError({ reason: `${label.charAt(0).toUpperCase()}${label.slice(1)} must be greater than 0` })
17
25
  return amount
18
26
  }