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,401 @@
1
+ // Coordinator sessions in the OpenCode process: their role instructions (below)
2
+ // and the prompt cache warmup of Kimaki sessions.
3
+ //
4
+ // Warmup works like OpenCode's `warming` (core/src/plugin/warming.ts): a
5
+ // `session.generate` keep-alive reads the cached prompt prefix, which keeps the
6
+ // provider cache alive, and stores nothing in the session. It runs while a
7
+ // coordinator's channel is busy, so the <session-finished> note of a worker
8
+ // reads a warm cache, and while a session sleeps (`kimaki sleep` of at most 45
9
+ // minutes), so its wake does:
10
+ //
11
+ // model request ─▶ intervalMs later: sleeping, or coordinator ─▶ keep-alive, again intervalMs later
12
+ // channel busy (at most maxMs
13
+ // after the last real request)?
14
+ // └─▶ no ─▶ end
15
+ // end of a coordinator ─▶ compact while the cache still holds, when the context is big
16
+ //
17
+ // Only the bot knows the threads of a channel and the sleeps: it answers
18
+ // `session.warmup` on its lock port. The warmup is also in plugin storage, so a
19
+ // plugin reload (every Kimaki build or upgrade) continues it. Root Kimaki
20
+ // sessions only, never subagents or TUI sessions.
21
+ // TODO: let the coordinator compact worker sessions too (they keep their full history now).
22
+
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import type { Plugin } from '@opencode/plugin'
26
+ import type { SessionContext } from '@opencode/plugin/promise/session'
27
+ import dedent from 'string-dedent'
28
+
29
+ // The whole answer of a coordinator turn with nothing for the user. The reducer drops it.
30
+ export const IDLE_REPLY = 'IDLE'
31
+
32
+ // OpenCode's warming defaults: a keep-alive every 4 minutes stays under Anthropic's
33
+ // 5-minute cache TTL. Same prompt, so OpenCode's own warming, when on, ignores ours.
34
+ const WARM_INTERVAL_MS = 4 * 60_000
35
+ const WARM_PROMPT = 'This is a keep-alive request. Do not perform any work or use tools. Reply with exactly: OK'
36
+ // A stuck worker must not keep the coordinator warm forever.
37
+ const WARM_MAX_MS = 90 * 60_000
38
+ // A small history is cheap to keep: compacting it only loses detail. Tokens, not a
39
+ // share of the window: a coordinator at 190k never reached 20% of a 1M window.
40
+ const MIN_COMPACT_TOKENS = 100_000
41
+ // Missed while no plugin ran (OpenCode was down): the cache may be cold.
42
+ const STALE_MS = 60_000
43
+ const BUSY_TIMEOUT_MS = 5_000
44
+ const STORAGE_PREFIX = 'warm/'
45
+
46
+ type Timer = ReturnType<typeof setTimeout>
47
+ type Windows = { intervalMs: number; maxMs: number; coordinator: boolean }
48
+ // last: the latest model request, keep-alives included. active: the latest real one.
49
+ type Warmup = { directory: string; last: number; active: number }
50
+
51
+ function markerValue(session: RoleSession, key: string): unknown {
52
+ const kimaki = session.metadata?.['kimaki']
53
+ return kimaki && typeof kimaki === 'object' ? Reflect.get(kimaki, key) : null
54
+ }
55
+
56
+ // Root Kimaki sessions only. A `warmup` marker field overrides the windows (tests).
57
+ function readWindows(session: RoleSession): Windows | null {
58
+ if (session.parentID || !markerString(session, 'threadId')) return null
59
+ const warmup = markerValue(session, 'warmup')
60
+ const read = (key: string, fallback: number) => {
61
+ const value: unknown = warmup && typeof warmup === 'object' ? Reflect.get(warmup, key) : null
62
+ return typeof value === 'number' && value > 0 ? value : fallback
63
+ }
64
+ return { intervalMs: read('intervalMs', WARM_INTERVAL_MS), maxMs: read('maxMs', WARM_MAX_MS), coordinator: !!coordinatorChannel(session) }
65
+ }
66
+
67
+ function readWarmup(value: unknown): Warmup | null {
68
+ if (!value || typeof value !== 'object') return null
69
+ const directory: unknown = Reflect.get(value, 'directory')
70
+ const last: unknown = Reflect.get(value, 'last')
71
+ const active: unknown = Reflect.get(value, 'active')
72
+ return typeof directory === 'string' && typeof last === 'number' && typeof active === 'number' ? { directory, last, active } : null
73
+ }
74
+
75
+ // busy: a coordinator or a thread of its channel runs. sleeping: a sleep of at most
76
+ // 45 minutes waits for its wake. From the bot (lock-routes.ts); no answer (bot stopped) ends the warmup.
77
+ async function warmupState(session: RoleSession): Promise<{ busy: boolean; sleeping: boolean }> {
78
+ const idle = { busy: false, sleeping: false }
79
+ const dataDir = markerString(session, 'dataDir')
80
+ const lockPort = markerValue(session, 'lockPort')
81
+ if (!dataDir || typeof lockPort !== 'number') return idle
82
+ const token = await fs.promises.readFile(path.join(dataDir, 'lock-token'), 'utf8').catch(() => null)
83
+ if (token === null) return idle
84
+ const response = await fetch(`http://127.0.0.1:${lockPort}/kimaki/session.warmup`, {
85
+ method: 'POST',
86
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
87
+ body: JSON.stringify({ sessionId: session.id }),
88
+ signal: AbortSignal.timeout(BUSY_TIMEOUT_MS),
89
+ }).catch(() => null)
90
+ if (!response?.ok) return idle
91
+ const body: unknown = await response.json().catch(() => null)
92
+ if (!body || typeof body !== 'object') return idle
93
+ return { busy: Reflect.get(body, 'busy') === true, sleeping: Reflect.get(body, 'sleeping') === true }
94
+ }
95
+
96
+ export async function registerWarmup(ctx: Plugin.Context): Promise<() => void> {
97
+ // The marker never changes after create: one session.get per session, also for TUI ones.
98
+ const known = new Map<string, Promise<Windows | null>>()
99
+ const warmups = new Map<string, Warmup>()
100
+ const timers = new Map<string, Timer>()
101
+
102
+ function windows(sessionID: string): Promise<Windows | null> {
103
+ const cached = known.get(sessionID)
104
+ if (cached) return cached
105
+ // A failed read is not cached: it would turn the warmup off until the plugin reloads.
106
+ const found = ctx.session.get({ sessionID }).then(readWindows, () => {
107
+ known.delete(sessionID)
108
+ return null
109
+ })
110
+ known.set(sessionID, found)
111
+ return found
112
+ }
113
+
114
+ async function worthCompacting(sessionID: string): Promise<boolean> {
115
+ const messages = await ctx.session.context({ sessionID })
116
+ const last = messages.findLast((message) => message.type === 'assistant' && message.tokens)
117
+ const tokens = last?.type === 'assistant' && last.tokens ? last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write : 0
118
+ return tokens >= MIN_COMPACT_TOKENS
119
+ }
120
+
121
+ function save(sessionID: string, warmup: Warmup) {
122
+ warmups.set(sessionID, warmup)
123
+ void ctx.storage.set(STORAGE_PREFIX + sessionID, warmup).catch(() => undefined)
124
+ }
125
+
126
+ function arm(sessionID: string, found: Windows) {
127
+ const warmup = warmups.get(sessionID)
128
+ if (!warmup) return
129
+ clearTimeout(timers.get(sessionID))
130
+ const timer = setTimeout(() => {
131
+ timers.delete(sessionID)
132
+ // Plugins stay silent: a failed keep-alive or compaction only loses that one.
133
+ void tick(sessionID, warmup, found).catch(() => undefined)
134
+ }, Math.max(0, warmup.last + found.intervalMs - Date.now()))
135
+ timer.unref?.()
136
+ timers.set(sessionID, timer)
137
+ }
138
+
139
+ async function tick(sessionID: string, warmup: Warmup, found: Windows) {
140
+ const session = await ctx.session.get({ sessionID }).catch(() => null)
141
+ const state = session ? await warmupState(session) : null
142
+ // The bot bounds sleeps (45 minutes); the plugin bounds busy channels, against stuck workers.
143
+ const warm = !!state && (state.sleeping || (state.busy && Date.now() < warmup.active + found.maxMs))
144
+ // A model request during the checks moved the warmup.
145
+ if (warmups.get(sessionID) !== warmup) return
146
+ if (warm) {
147
+ // Before the request, so a failing keep-alive waits a whole interval.
148
+ save(sessionID, { ...warmup, last: Date.now() })
149
+ arm(sessionID, found)
150
+ await ctx.session.generate({ sessionID, prompt: WARM_PROMPT })
151
+ return
152
+ }
153
+ warmups.delete(sessionID)
154
+ await ctx.storage.remove(STORAGE_PREFIX + sessionID).catch(() => undefined)
155
+ if (found.coordinator && (await worthCompacting(sessionID))) await ctx.session.compact({ sessionID, delivery: 'queue' })
156
+ }
157
+
158
+ async function touch(sessionID: string, real: boolean) {
159
+ const found = await windows(sessionID)
160
+ if (!found) return
161
+ const current = warmups.get(sessionID)
162
+ // Keep-alives (also OpenCode's own warming) only move a running warmup.
163
+ if (!real && !current) return
164
+ const now = Date.now()
165
+ save(sessionID, { directory: ctx.location.directory, last: now, active: real || !current ? now : current.active })
166
+ arm(sessionID, found)
167
+ }
168
+
169
+ // Warmups of the plugin before a reload. Storage is shared by all locations: each takes its own.
170
+ const stored = await ctx.storage.scan({ prefix: STORAGE_PREFIX }).catch(() => null)
171
+ await Promise.all((stored?.entries ?? []).map(async ({ key, value }) => {
172
+ const sessionID = key.slice(STORAGE_PREFIX.length)
173
+ const warmup = readWarmup(value)
174
+ if (!sessionID || warmup?.directory !== ctx.location.directory) return
175
+ const found = await windows(sessionID)
176
+ if (!found || Date.now() > warmup.last + found.intervalMs + STALE_MS) {
177
+ await ctx.storage.remove(key).catch(() => undefined)
178
+ return
179
+ }
180
+ warmups.set(sessionID, warmup)
181
+ arm(sessionID, found)
182
+ }))
183
+
184
+ // Turns of the session start or move the warmup; compaction requests do not.
185
+ await ctx.session.hook('context', (event) => touch(event.sessionID, true))
186
+ await ctx.session.hook('generate', (event) => touch(event.sessionID, false))
187
+
188
+ // Storage keeps the warmups for the next plugin instance.
189
+ return () => {
190
+ for (const timer of timers.values()) clearTimeout(timer)
191
+ timers.clear()
192
+ warmups.clear()
193
+ known.clear()
194
+ }
195
+ }
196
+
197
+ // Role instructions of coordinator sessions, derived from markers only:
198
+ //
199
+ // root Kimaki session ─▶ marker threadId === channelId? ─▶ coordinator
200
+ // └─▶ parent chain (marker parentSessionId, fork
201
+ // source) reaches a coordinator? ─▶ worker
202
+ //
203
+ // Here, not in the instructions entry the bot writes at session.create: that
204
+ // entry is frozen, while OpenCode reloads this plugin when its code changes.
205
+ // OpenCode has no instruction source for plugins, so the text is a synthetic
206
+ // message, reconciled like its plan plugin (core/src/plugin/plan.ts):
207
+ //
208
+ // user prompt ─▶ latest role text in the history missing (new session,
209
+ // compaction) or stale (new plugin code)? ─▶ append it before the prompt
210
+ //
211
+ // It lands at the tail: the cached prompt prefix stays warm. A turn with no user
212
+ // prompt (a <session-finished> wake after compaction) gets the text on its last
213
+ // user message for that request only, until the next prompt stores it.
214
+
215
+ const ROLE_HEAD = '<system-reminder>\n<kimaki-coordinator>'
216
+ // Workers of workers, forks of forks: bounded against cycles.
217
+ const MAX_PARENT_DEPTH = 10
218
+
219
+ function roleText(body: string): string {
220
+ return [ROLE_HEAD, 'These instructions replace any earlier Kimaki role instructions.', '', body, '</kimaki-coordinator>', '</system-reminder>'].join('\n')
221
+ }
222
+
223
+ type RoleSession = Awaited<ReturnType<Plugin.Context['session']['get']>>
224
+
225
+ function markerString(session: RoleSession, key: string): string | null {
226
+ const kimaki = session.metadata?.['kimaki']
227
+ const value: unknown = kimaki && typeof kimaki === 'object' ? Reflect.get(kimaki, key) : null
228
+ return typeof value === 'string' ? value : null
229
+ }
230
+
231
+ function coordinatorChannel(session: RoleSession): string | null {
232
+ const channelId = markerString(session, 'channelId')
233
+ return channelId && markerString(session, 'threadId') === channelId ? channelId : null
234
+ }
235
+
236
+ function parentOf(session: RoleSession): string | undefined {
237
+ return markerString(session, 'parentSessionId') ?? session.fork?.sessionID
238
+ }
239
+
240
+ export async function registerCoordinatorRoles(ctx: Plugin.Context): Promise<void> {
241
+ // The role text this session must have, or null: TUI sessions, subagents, plain threads.
242
+ async function expected(sessionID: string): Promise<string | null> {
243
+ const session = await ctx.session.get({ sessionID }).catch(() => null)
244
+ if (!session || session.parentID || !markerString(session, 'threadId')) return null
245
+ const channelId = coordinatorChannel(session)
246
+ const dataDir = markerString(session, 'dataDir')
247
+ if (channelId) return dataDir ? roleText(coordinatorSection({ sessionId: sessionID, channelId, dataDir })) : null
248
+ let parent = parentOf(session)
249
+ for (let depth = 0; parent && depth < MAX_PARENT_DEPTH; depth++) {
250
+ const info = await ctx.session.get({ sessionID: parent }).catch(() => null)
251
+ if (!info) return null
252
+ const coordinator = coordinatorChannel(info)
253
+ if (coordinator) return roleText(workerSection(coordinator))
254
+ parent = parentOf(info)
255
+ }
256
+ return null
257
+ }
258
+
259
+ await ctx.session.hook('prompt', async (event) => {
260
+ const text = await expected(event.sessionID)
261
+ if (!text) return
262
+ const history = await ctx.session.context({ sessionID: event.sessionID }).catch(() => null)
263
+ if (!history) return
264
+ const latest = history.findLast((message) => message.type === 'synthetic' && message.text.startsWith(ROLE_HEAD))
265
+ if (latest?.type === 'synthetic' && latest.text === text) return
266
+ // Plugins stay silent: a failed append is retried at the next prompt.
267
+ await ctx.session.synthetic({ sessionID: event.sessionID, text, delivery: event.delivery, resume: false }).catch(() => undefined)
268
+ })
269
+
270
+ // Every request kind gets the same messages, or the prompt cache misses (plugin/index.ts).
271
+ const shapeRequest = async (event: SessionContext) => {
272
+ const text = await expected(event.sessionID)
273
+ if (!text) return
274
+ const texts = event.messages.flatMap((message) => (message.role === 'user' ? message.content : []))
275
+ .flatMap((part) => (part.type === 'text' && part.text.startsWith(ROLE_HEAD) ? [part.text] : []))
276
+ if (texts.at(-1) === text) return
277
+ const last = event.messages.findLast((message) => message.role === 'user')
278
+ // TODO: insert a new Message like core/src/plugin/plan.ts once plugins can import OpenCode's Message class.
279
+ if (last) Array.prototype.push.call(last.content, { type: 'text', text })
280
+ }
281
+ await ctx.session.hook('context', shapeRequest)
282
+ await ctx.session.hook('compaction', shapeRequest)
283
+ await ctx.session.hook('generate', shapeRequest)
284
+ }
285
+
286
+ // The coordinator of a coordinator channel.
287
+ function coordinatorSection({ sessionId, channelId, dataDir }: { sessionId: string; channelId: string; dataDir: string }): string {
288
+ return dedent`
289
+ ## coordinator: you coordinate this channel
290
+
291
+ You run directly in a Discord **channel**, not in a thread. This is one long session the user keeps coming back to, for weeks. Your job is to know what is going on in this project and to get work done through **thread sessions**, which the user can open and talk to. This session is meant to be **infinite**: it is compacted over and over, and every file you read or command output you see fills it up. Keep your context clean: it holds goals, decisions and results, not working details.
292
+
293
+ ### delegate the work
294
+
295
+ - Read files, diffs and the repo source yourself to know what is going on: how the project works, what it does and what workers changed. You need this to answer the user and to write good worker prompts. Not everything needs a worker.
296
+ - Delegate the work itself to child sessions, \`kimaki send\` threads or the \`subagent\` tool: editing files, builds, tests, long debugging and big research.
297
+ - Use the \`subagent\` tool only for blocking throwaway work the user never needs to see, to save your context: an \`explore\` search, a quick lookup. Anything the user may want to follow or steer is a thread.
298
+ - When you can answer from what you know, answer directly.
299
+ - Start a worker:
300
+
301
+ kimaki send --channel ${channelId} --name 'Short title' --prompt '...' --agent <agent> --parent-session ${sessionId} --wait 5
302
+
303
+ Do not add the user to worker threads by default. Pass \`--user '<user-id>'\` (the \`user-id\` of the latest \`<discord-user>\`) only when the user asks to follow the thread, or for planning tasks and long work where the user will probably take part.
304
+ - \`--wait 5\` is the default for every \`kimaki send\`: it waits at most 5 seconds. When the run ends in time, the command prints its answer and no note comes: report it right away like a note. When it prints \`is still running after 5s\`, the run goes on and its \`<session-finished>\` note wakes you when it ends.
305
+ - After starting a worker that is still running, say nothing: your whole reply is exactly \`IDLE\`. The user already sees the new thread start in Discord, so a notice like "Started a worker for X" only repeats it. You report the outcome when its note wakes you. Speak before that only when the user really needs it now: an answer to a question they asked, a decision only they can make, or a blocker.
306
+ - Use \`--wait\` with no number (waits until the run ends; shell timeout 20 minutes or more, \`timeout: 1_500_000\`) only when you need the result before your next step.
307
+ - Other projects: \`--channel\` of that project (\`kimaki project list --json\`). Code changes that could conflict with other running sessions: \`--worktree <name>\`.
308
+ - Run sessions in parallel only when their write scopes do not overlap. Otherwise use separate worktrees, or run them one after another.
309
+ - A follow-up for an existing worker: \`kimaki send --thread <thread_id> --prompt '...' --wait 5\`, with \`. queue\` at the end of the prompt when it may be busy. Prefer continuing a worker that has the context over re-explaining it to a new one, except a cold long one: over 400k tokens of context and last active more than 10 minutes ago (the \`context\` of its \`<session-finished>\` note, or the header of \`kimaki session read <id>\`). Its prompt cache has expired, so a follow-up would write the whole context again. Start a new worker instead, with the old thread's Discord URL in the prompt so it reads that session with \`kimaki session read <url> --thinking\`. A follow-up shows only inside its thread, never in this channel. When it is answered within the wait, report the answer with the thread link. When it is still running or queued, tell the user in one short sentence what you sent, with the thread link \`<#thread_id>\`.
310
+ - Related work that needs the same context, in parallel: fork a worker (or a past session) with \`. btw\` at the end, only when it touches different files than the work still running there:
311
+
312
+ kimaki send --thread <thread_id> --prompt '... btw' --agent <agent> --parent-session ${sessionId} --wait 5
313
+
314
+ The fork gets a new thread with the whole history; the source keeps running. The new thread shows, so when it is still running reply \`IDLE\` like after starting a worker. Its \`<session-finished>\` note wakes you like any worker.
315
+ - Link a worker as \`<#thread_id>\` once, in your report of its outcome: no other notice shows that it finished. Also once when you send it a follow-up that is still running. Nowhere else: no thread links, session URLs or session IDs in other replies, unless the user asks for one.
316
+
317
+ ### writing prompts for workers
318
+
319
+ A worker starts with no memory of this conversation. It loads AGENTS.md and the project instructions itself: never tell it to read them or repeat their rules. The user reads the prompt in Discord, so keep it **concise**: no headings, short bullet lists, only **bold**, lists and \`code\`. Include only what the worker needs and cannot find on its own.
320
+
321
+ - Always quote the user's words verbatim, in a \`>\` quote. Never summarize or reword them. Add only the extra context the worker really needs: relevant files and folders, decisions already made, constraints.
322
+ - Say **what** the user wants, not **how** to do it: the goal, and what done means (the result to expect). No steps, test commands or build instructions; the worker finds those in AGENTS.md.
323
+ - Scope: what it may change, and what it must not do. No commits, pushes, PRs or messages to external systems unless the user asked.
324
+ - One worker owns one coherent task. Do not split a task into tiny sessions that depend on each other.
325
+ - Recurring how-to (how to run tests, which commands, build steps) and any rule you repeat in many prompts belong in AGENTS.md, not in prompts. When one is missing there, suggest adding it to the user.
326
+ - Pick \`--agent\` by difficulty and cost: a strong model for design and hard debugging, a cheaper one for mechanical edits and searches.
327
+
328
+ ### notifications
329
+
330
+ - When a run you prompted ends (a worker you started, a follow-up or a fork you sent), a \`<session-finished ...>\` message with its final answer wakes you. Review the real change before you report it, not only the worker's claims: read each critique diff as a patch (\`curl -s <critique url>.patch\`, for example \`curl -s https://critique.work/v/<id>.patch\`) and the changed files when the patch is not enough. Check the result against the goal and start the next step yourself when the goal is not reached. Then tell the user the outcome in a few plain sentences, with the thread link once. The user sees nothing else about it. A worker you started on your own (a check, an intermediate step) is reported only when its result matters to the user; otherwise reply \`IDLE\`.
331
+ - Files a worker uploads (screenshots, images) show in its thread; the thread link points the user to them. Never upload them again and never ask a worker for their URLs.
332
+ - Every report of changed files or commits has critique diff URLs, one per commit (\`bunx critique --commit <hash> --web 'title'\`). When the worker's answer has changes or commit hashes without URLs, make them yourself before you reply.
333
+ - A run that ends within its \`--wait\` returns its answer in the command output instead, with no note. Report it the same way. When a wait with no number is cut off (shell timeout, interrupt), check the worker yourself: its note may not come.
334
+ - Runs you did not prompt add a \`<session-finished ... wake="false">\` note without waking you: the user writing in a worker thread directly (the note has their \`<user-prompt>\`), other threads of this channel and routines. Read them at the next prompt so you know what happened meanwhile.
335
+ - Reminders, notes and wakes are not a reason to talk. Say something only when it is important and the user should really see it: a finished result they asked for, a failure, a blocker, or a decision only they can make. Otherwise your whole reply is exactly \`IDLE\`, also after running tools or starting work: Kimaki hides it. Never post status updates, plans, recaps of what you did, or filler like "nothing new" or "you already see it in that thread".
336
+ - Starting a worker is not finishing it. When you need its result and no note came, check instead of guessing.
337
+ - Stop a worker's run with \`kimaki session abort <id>\`; its thread stays and can continue later.
338
+ - \`<session-finished>\` notes and reminders are for you. Never mention them to the user.
339
+ - Never say a worker is still working without checking: \`kimaki session list --active\` or \`kimaki session read <id>\`.
340
+ - \`kimaki session read <id>\` prints the prompts and final answers of a session, with its context size and last activity in the header; \`--thinking\` adds every tool call and the reasoning. Search older work of this and other projects in \`${dataDir}/transcripts\` (see "reading other sessions"), for example to learn what a session did or to debug it.
341
+
342
+ ### examples
343
+
344
+ Lines in brackets are your tool calls and wakes, not text the user sees.
345
+
346
+ \`\`\`
347
+ user: the login test is flaky, fix it
348
+ you: [kimaki send --channel ... --name 'Fix flaky login test' ... --wait 5, thread 1501 starts, still running after 5s]
349
+ you: IDLE
350
+ user: also make that timeout configurable
351
+ you: [1501 still runs: kimaki send --thread 1501 --prompt '... queue' --wait 5, still running after 5s]
352
+ you: Queued in <#1501>: a configurable login timeout, after the flaky test fix.
353
+ user: and add a logout test meanwhile
354
+ you: [kimaki send --thread 1501 --prompt '... btw' --wait 5, fork thread 1503 starts, still running after 5s]
355
+ you: IDLE
356
+ [<session-finished> of the first run of 1501 wakes you; you read its critique patch]
357
+ you: Fixed in <#1501>: the test now waits for the session cookie before it clicks. https://critique.work/v/a1b2c3
358
+ [<session-finished> of the fork wakes you]
359
+ you: The logout test is added in <#1503>. https://critique.work/v/7f8e9d
360
+ [<session-finished> of the queued run of 1501 wakes you]
361
+ you: The login timeout is now the LOGIN_TIMEOUT_MS env var, 10s by default, in <#1501>. https://critique.work/v/d4e5f6
362
+ user: ask it if it ran the full test suite
363
+ you: [kimaki send --thread 1501 --prompt '...' --wait 5, answered in 3s]
364
+ you: Yes, <#1501> ran the full suite and all tests pass.
365
+ user: now remove the old login fallback
366
+ you: [kimaki send --thread 1501 --prompt '...' --wait 5, still running after 5s]
367
+ you: Sent to <#1501>: remove the old login fallback.
368
+
369
+ user: which database does billing use? and add an index on invoices.user_id
370
+ you: [reads db/schema.prisma, starts a worker for the index]
371
+ you: Billing uses the shared Postgres in db/schema.prisma.
372
+ [<session-finished> wakes you]
373
+ you: The index on invoices.user_id is added in <#1502>. https://critique.work/v/0a9b8c
374
+
375
+ user: refactor the auth module
376
+ you: [finds two auth modules, starts nothing]
377
+ you: Which one: website/src/auth.ts or gateway-proxy/src/auth.rs?
378
+ \`\`\`
379
+
380
+ ### memory
381
+
382
+ Keep durable project memory in \`MEMORY.md\` at the project root, tracked in git: decisions, user preferences, open goals, where things live. One \`##\` section per topic, short bullets. Update it when you learn something that must outlive compaction.
383
+ The headings are the index. Never read the whole file; list them, then read only the sections you need:
384
+
385
+ \`\`\`bash
386
+ rg -n '^#+ ' MEMORY.md
387
+ \`\`\`
388
+ `
389
+ }
390
+
391
+ // A thread of a coordinator channel. A btw fork carries the coordinator's history: it must not take its role.
392
+ // The channel ID always resolves to the current coordinator, also after /reset-coordinator.
393
+ function workerSection(channelId: string): string {
394
+ return dedent`
395
+ ## worker of a coordinator
396
+
397
+ This channel has a coordinator session. You are a worker, not the coordinator, even if your history shows its context: do only your own task.
398
+ For high level project context (goals, decisions, what other threads did), read it: \`kimaki session read ${channelId}\`. Project memory: \`rg -n '^#+ ' MEMORY.md\`, then read only the sections you need.
399
+ In your final message, list your critique diff URLs. When you commit, give one critique URL per commit (\`bunx critique --commit <hash> --web 'title'\`), not only the hashes.
400
+ `
401
+ }
@@ -1,5 +1,5 @@
1
- import type { JsonValue } from '@opencode/client'
2
1
  import { Plugin, Skill } from '@opencode/plugin'
2
+ import type { SessionContext } from '@opencode/plugin/promise/session'
3
3
  import { Schema } from 'effect'
4
4
  import { execFile } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
@@ -8,13 +8,28 @@ import path from 'node:path'
8
8
  import { autostartBot } from '../autostart.ts'
9
9
  import { readBuiltinSkills } from '../builtin-skills.ts'
10
10
  import { fileEditTool, recordToolEdits } from '../file-edit-log.ts'
11
- import { MESSAGE_MODE_INSTRUCTIONS, MESSAGE_TOOL, MESSAGE_TOOL_DESCRIPTION, MESSAGE_TOOL_INPUT, turnContext } from '../system-prompt.ts'
11
+ import { registerCoordinatorRoles, registerWarmup } from './coordinator.ts'
12
+ import { turnContext } from '../system-prompt.ts'
12
13
 
13
14
  const exec = promisify(execFile)
14
15
 
15
16
  const decodeSkill = Schema.decodeUnknownSync(Skill.Info)
16
17
 
17
18
  const BRANCH_PREFIX = '[current git branch is '
19
+ // Prompt metadata key: ms between the latest assistant message and the prompt.
20
+ const GAP_KEY = 'sinceAssistantMs'
21
+
22
+ // `3d 4h`, `2h 5m`, `12m 5s`, `40s`: enough to tell a quick follow-up from a return after a break.
23
+ function formatGap(ms: number): string {
24
+ const seconds = Math.floor(ms / 1_000)
25
+ const minutes = Math.floor(seconds / 60)
26
+ const hours = Math.floor(minutes / 60)
27
+ const days = Math.floor(hours / 24)
28
+ if (days > 0) return `${days}d ${hours % 24}h`
29
+ if (hours > 0) return `${hours}h ${minutes % 60}m`
30
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`
31
+ return `${seconds}s`
32
+ }
18
33
 
19
34
  export default Plugin.define({
20
35
  id: 'kimaki',
@@ -32,78 +47,59 @@ export default Plugin.define({
32
47
  }
33
48
  })
34
49
  }
35
- // metadata.kimaki of the session, or of the thread's root for a subagent (`root: false`).
36
- async function marker(sessionID: string): Promise<{ fields: Map<string, JsonValue>; root: boolean } | null> {
50
+ async function marker(sessionID: string) {
37
51
  const session = await ctx.session.get({ sessionID }).catch(() => null)
38
52
  const value: unknown = session?.metadata?.['kimaki']
39
53
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null
40
54
  const current = new Map(Object.entries(value))
41
- if (!session?.parentID) return { fields: current, root: true }
55
+ if (!session?.parentID) return current
42
56
  const parent = await ctx.session.get({ sessionID: session.parentID }).catch(() => null)
43
57
  const parentMarker = parent?.metadata?.['kimaki']
44
- if (parentMarker && typeof parentMarker === 'object' && !Array.isArray(parentMarker) && parentMarker['threadId'] === current.get('threadId')) {
45
- const root = await marker(session.parentID)
46
- return root && { fields: root.fields, root: false }
47
- }
48
- return { fields: current, root: true }
58
+ if (parentMarker && typeof parentMarker === 'object' && !Array.isArray(parentMarker) && parentMarker['threadId'] === current.get('threadId')) return marker(session.parentID)
59
+ return current
49
60
  }
50
- // Registered for every session; the context hook removes it outside message mode.
51
- await ctx.tool.transform((editor) => {
52
- editor.add({
53
- name: MESSAGE_TOOL,
54
- description: MESSAGE_TOOL_DESCRIPTION,
55
- input: MESSAGE_TOOL_INPUT,
56
- options: { codemode: false },
57
- // Discord renders the call input (thread-reducer.ts); the tool itself does nothing.
58
- execute: async () => ({ content: 'Message sent to the user.' }),
59
- })
60
- })
61
- // The git branch reaches the model as a synthetic message saved right before
62
- // the user prompt, only when it changed. Text in event.system would change the
63
- // cached prompt prefix on every branch switch. The prompt hook runs before
64
- // OpenCode admits the prompt, so the synthetic message is admitted first.
61
+ // The prompt hook runs before OpenCode admits the prompt, so changes to its
62
+ // metadata are stored with it and a synthetic message saved here comes first.
65
63
  await ctx.session.hook('prompt', async (event) => {
66
64
  if (!(await marker(event.sessionID))) return
67
- const branch = await exec('git', ['branch', '--show-current'], { cwd: ctx.location.directory, timeout: 5000 }).catch(() => null)
65
+ const [history, branch] = await Promise.all([
66
+ ctx.session.context({ sessionID: event.sessionID }).catch(() => null),
67
+ exec('git', ['branch', '--show-current'], { cwd: ctx.location.directory, timeout: 5000 }).catch(() => null),
68
+ ])
69
+ if (!history) return
70
+ // The time since the latest reply is stored on the prompt and rendered by
71
+ // shapeRequest: a value computed per request would change the cached prefix.
72
+ const reply = history.findLast((message) => message.type === 'assistant')
73
+ if (reply?.type === 'assistant') event.metadata = { ...event.metadata, [GAP_KEY]: Math.max(0, Date.now() - (reply.time.completed ?? reply.time.created)) }
74
+ // The git branch is a synthetic message, only when it changed. Text in
75
+ // event.system would change the cached prompt prefix on every branch switch.
68
76
  const name = branch?.stdout.trim()
69
77
  if (!name) return
70
78
  const text = `${BRANCH_PREFIX}${name}]`
71
- const history = await ctx.session.context({ sessionID: event.sessionID }).catch(() => null)
72
- if (!history) return
73
79
  const last = history.findLast((message) => message.type === 'synthetic' && message.text.startsWith(BRANCH_PREFIX))
74
80
  if (last?.type === 'synthetic' && last.text === text) return
75
81
  await ctx.session.synthetic({ sessionID: event.sessionID, text, delivery: event.delivery, resume: false }).catch(() => undefined)
76
82
  })
77
- // send_message only for root sessions of a bot started with --message-tool.
78
- // Every request kind that carries the tool list gets the same tools and system
79
- // text, so the prompt cache keeps hitting and no other session ever sees the tool.
80
- async function applyMessageMode(event: { sessionID: string; tools: Record<string, unknown>; system: Array<{ type: 'text'; text: string }> }) {
81
- const info = await marker(event.sessionID)
82
- const messageMode = info?.root === true && info.fields.get('messageTool') === true
83
- if (!messageMode) delete event.tools[MESSAGE_TOOL]
84
- else event.system.push({ type: 'text', text: MESSAGE_MODE_INSTRUCTIONS })
85
- return info
86
- }
87
- await ctx.session.hook('compaction', async (event) => {
88
- await applyMessageMode(event)
89
- })
90
- await ctx.session.hook('generate', async (event) => {
91
- await applyMessageMode(event)
92
- })
93
- await ctx.session.hook('context', async (event) => {
83
+ // Runs for every request kind: turns (context), compaction and generate (OpenCode
84
+ // `warming` keep-alive requests). All kinds must get the same system text, tools
85
+ // and messages, or the provider prompt cache misses.
86
+ const shapeRequest = async (event: SessionContext) => {
94
87
  // The only prompt change for every session, TUI included. The ID never
95
88
  // changes in a session, so the cached prompt prefix stays stable.
96
89
  event.system.push({ type: 'text', text: `OpenCode session ID: ${event.sessionID}` })
97
- const info = await applyMessageMode(event)
98
- if (!info) return
99
- // The <discord-user> context of each prompt, built from its metadata, so the
100
- // stored text stays clean for the TUI and other clients. Same metadata, same
101
- // text on every request: the cached prompt prefix stays stable.
90
+ if (!(await marker(event.sessionID))) return
91
+ // The time since the previous reply and the <discord-user> context of each
92
+ // prompt, built from its metadata, so the stored text stays clean for the TUI
93
+ // and other clients. Same metadata, same text on every request: the cached
94
+ // prompt prefix stays stable.
102
95
  for (const message of event.messages) {
103
- const context = message.role === 'user' ? turnContext(message.metadata) : null
104
- // TODO: build a new Message once plugins can import OpenCode's Message class;
105
- // a copy from another module instance may fail its instanceof checks.
106
- if (context) Array.prototype.push.call(message.content, { type: 'text', text: context })
96
+ if (message.role !== 'user') continue
97
+ const gap = message.metadata?.[GAP_KEY]
98
+ for (const text of [typeof gap === 'number' ? `[${formatGap(gap)} since your last message]` : null, turnContext(message.metadata)]) {
99
+ // TODO: build a new Message once plugins can import OpenCode's Message class;
100
+ // a copy from another module instance may fail its instanceof checks.
101
+ if (text) Array.prototype.push.call(message.content, { type: 'text', text })
102
+ }
107
103
  }
108
104
  // Extra inputs only for the Discord tool line. OpenCode decodes the real
109
105
  // inputs with Schema.Struct, which ignores unknown keys.
@@ -121,25 +117,56 @@ export default Plugin.define({
121
117
  ...properties,
122
118
  } }
123
119
  }
124
- })
120
+ }
121
+ await ctx.session.hook('context', shapeRequest)
122
+ await ctx.session.hook('compaction', shapeRequest)
123
+ await ctx.session.hook('generate', shapeRequest)
124
+ // On Windows the shell is often PowerShell or cmd.exe, which cannot run the POSIX
125
+ // prefix, and Git Bash splits a `C:\...` PATH entry on `:`. There shell create.before
126
+ // swaps the prefix for env vars, which every shell inherits (MSYS converts the
127
+ // Windows PATH itself). That hook has no session or tool call, so the prefixed
128
+ // command, unique per tool call, is the key.
129
+ const windowsShells = new Map<string, { command: string; bin: string; env: Record<string, string> }>()
125
130
  await ctx.tool.hook('execute.before', async (event) => {
126
131
  if (event.tool !== 'shell' || !event.input || typeof event.input !== 'object' || Array.isArray(event.input)) return
127
132
  const info = await marker(event.sessionID)
128
- const dataDir = info?.fields.get('dataDir')
129
- const lockPort = info?.fields.get('lockPort')
133
+ const dataDir = info?.get('dataDir')
134
+ const lockPort = info?.get('lockPort')
130
135
  if (typeof dataDir !== 'string' || typeof lockPort !== 'number') return
131
136
  if (!('command' in event.input) || typeof event.input.command !== 'string') return
132
137
  const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
133
- event.input.command = `export PATH=${quote(path.join(dataDir, 'bin'))}:"$PATH" KIMAKI_DATA_DIR=${quote(dataDir)} KIMAKI_LOCK_PORT=${quote(String(lockPort))} KIMAKI_TOOL_CALL=${quote(`${event.messageID}:${event.id}`)}; ${event.input.command}`
138
+ const bin = path.join(dataDir, 'bin')
139
+ const env = { KIMAKI_DATA_DIR: dataDir, KIMAKI_LOCK_PORT: String(lockPort), KIMAKI_TOOL_CALL: `${event.messageID}:${event.id}` }
140
+ const command = event.input.command
141
+ const prefixed = `export PATH=${quote(bin)}:"$PATH" ${Object.entries(env).map(([key, value]) => `${key}=${quote(value)}`).join(' ')}; ${command}`
142
+ event.input.command = prefixed
143
+ if (process.platform === 'win32') windowsShells.set(prefixed, { command, bin, env })
134
144
  })
145
+ if (process.platform === 'win32') {
146
+ await ctx.shell.hook('create.before', (event) => {
147
+ const pending = windowsShells.get(event.command)
148
+ if (!pending) return
149
+ windowsShells.delete(event.command)
150
+ event.command = pending.command
151
+ // Spread from process.env, so the key keeps its Windows case (usually `Path`).
152
+ const keys = Object.keys(event.env).filter((key) => key.toUpperCase() === 'PATH')
153
+ for (const key of keys.length > 0 ? keys : ['Path']) {
154
+ event.env[key] = [pending.bin, event.env[key]].filter(Boolean).join(path.delimiter)
155
+ }
156
+ Object.assign(event.env, pending.env)
157
+ })
158
+ }
159
+ await registerCoordinatorRoles(ctx)
160
+ const stopWarmup = await registerWarmup(ctx)
135
161
  // File edits feed `kimaki session editors` (v1-compatible JSONL index).
136
162
  await ctx.tool.hook('execute.after', async (event) => {
137
163
  if (event.status !== 'completed' || !fileEditTool(event.tool)) return
138
- const dataDir = (await marker(event.sessionID))?.fields.get('dataDir')
164
+ const dataDir = (await marker(event.sessionID))?.get('dataDir')
139
165
  if (typeof dataDir !== 'string') return
140
166
  // Plugins stay silent: a failed append only loses one index entry.
141
167
  const recorded = await recordToolEdits({ dataDir, directory: ctx.location.directory, sessionId: event.sessionID, tool: event.tool, input: event.input })
142
168
  if (recorded instanceof Error) return
143
169
  })
170
+ return stopWarmup
144
171
  },
145
172
  })
@@ -77,7 +77,7 @@ test('bot start writes the plugin shim, OpenCode loads it, a restart does not re
77
77
  --- from: assistant (TestBot)
78
78
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
79
79
  ok
80
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
80
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
81
81
  `)
82
82
 
83
83
  const written = fs.statSync(shim).mtimeMs