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
@@ -13,7 +13,8 @@ test('text-only turn: banner, text, typing, footer with context percent', () =>
13
13
  expect(effects).toMatchInlineSnapshot(`
14
14
  [
15
15
  {
16
- "text": "» **user:** Do these steps with tools, one by one: 1) run \`ls cli/src | head -5\` with bash, 2) write a file tmp-events/hello.txt containing "hello", 3) edit that file to replace hello with "hello world", 4) glob for **/*.txt under tmp-events, 5) read the file. Then reply with one short line.",
16
+ "echo": true,
17
+ "text": "-# » **user:** Do these steps with tools, one by one: 1) run \`ls cli/src | head -5\` with bash, 2) write a file tmp-events/hello.txt containing "hello", 3) edit that file to replace hello with "hello world", 4) glob for **/*.txt under tmp-events, 5) read the file. Then reply with one short line.",
17
18
  "type": "send",
18
19
  },
19
20
  {
@@ -26,6 +27,7 @@ test('text-only turn: banner, text, typing, footer with context percent', () =>
26
27
  },
27
28
  {
28
29
  "blankLineBefore": false,
30
+ "replyTo": null,
29
31
  "text": "Done. \`tmp-events/hello.txt\` contains \`hello world\`.",
30
32
  "type": "markdown",
31
33
  },
@@ -35,6 +37,7 @@ test('text-only turn: banner, text, typing, footer with context percent', () =>
35
37
  },
36
38
  {
37
39
  "agent": "build",
40
+ "channelId": "channel",
38
41
  "contextPercent": 6,
39
42
  "directory": "/project",
40
43
  "durationMs": 17597,
@@ -55,32 +58,65 @@ test('text-only turn: banner, text, typing, footer with context percent', () =>
55
58
  expect(view.turn).toBe(null)
56
59
  })
57
60
 
58
- test('message mode: send_message shows as markdown, plain text only when the turn sent nothing', () => {
61
+ test('a turn that posts nothing visible ends without a footer', () => {
62
+ const events = loadFixture('tools.events.jsonl')
63
+ // Read-only tools are hidden at the default verbosity.
64
+ const hiddenTools = new Set(events.flatMap((event) => (event.type === 'session.tool.input.started' && ['read', 'glob'].includes(event.data.name) ? [event.data.id] : [])))
65
+ const quiet = events.filter((event) => {
66
+ if (event.type.startsWith('session.text.')) return false
67
+ if (!event.type.startsWith('session.tool.')) return true
68
+ const data: { readonly [key: string]: JsonValue | undefined } = event.data
69
+ return typeof data['id'] === 'string' && hiddenTools.has(data['id'])
70
+ })
71
+ const footers = (input: ThreadEvent[]) => replay({ events: input }).effects.filter((effect) => effect.type === 'footer').length
72
+ expect(hiddenTools.size).toBe(2)
73
+ expect({ quiet: footers(quiet), normal: footers(events) }).toMatchInlineSnapshot(`
74
+ {
75
+ "normal": 1,
76
+ "quiet": 0,
77
+ }
78
+ `)
79
+ })
80
+
81
+ test('any turn that answers IDLE posts no text and no footer', () => {
59
82
  const turn = loadFixture('tools.events.jsonl').filter((event) => !event.type.startsWith('session.tool.'))
60
- const prefs = { ...DEFAULT_PREFS, messageTool: true }
61
- const textIndex = turn.findIndex((event) => event.type === 'session.text.ended')
62
- const text = turn[textIndex]!
63
- if (text.type !== 'session.text.ended') throw new Error('Expected text')
64
- const call = { sessionID: text.data.sessionID, assistantMessageID: text.data.assistantMessageID, id: 'call_message' }
65
- const durable = { aggregateID: text.data.sessionID, seq: 1, version: 1 as const }
66
- const message: V2Event[] = [
67
- { id: 'evt_message_input', created: text.created, durable, type: 'session.tool.input.started', data: { ...call, name: 'send_message' } },
68
- { id: 'evt_message_called', created: text.created, durable, type: 'session.tool.called', data: { ...call, executed: false, input: { text: 'Visible **reply**' } } },
69
- ]
70
- const sent = replay({ prefs, events: [...turn.slice(0, textIndex), ...message, ...turn.slice(textIndex)] })
71
- const silent = replay({ prefs, events: turn })
72
- const lines = (effects: typeof sent.effects) => effectLines(effects).filter((line) => !line.startsWith('»') && !line.startsWith('[typing'))
73
- expect({ sent: lines(sent.effects), silent: lines(silent.effects) }).toMatchInlineSnapshot(`
83
+ const idle = turn.map((event) => (event.type === 'session.text.ended' ? { ...event, data: { ...event.data, text: 'IDLE' } } : event))
84
+ const visible = replay({ events: idle }).effects.filter((effect) => effect.type === 'markdown' || effect.type === 'footer')
85
+ expect(visible).toEqual([])
86
+ })
87
+
88
+ test('a coordinator turn with tool lines but no text (or only IDLE) posts no footer; a thread turn does', () => {
89
+ const events = loadFixture('tools.events.jsonl')
90
+ const idle = events.map((event) => (event.type === 'session.text.ended' ? { ...event, data: { ...event.data, text: 'IDLE' } } : event))
91
+ const silent = events.filter((event) => !event.type.startsWith('session.text.'))
92
+ const sessionId = rootSessionId(events.filter((event): event is V2Event => !event.type.startsWith('kimaki.')))
93
+ const shown = (input: ThreadEvent[], coordinator: boolean) =>
94
+ replay({ events: input, view: emptyView({ sessionId, channelId: 'channel', directory: '/project', isNew: true, coordinator }) }).effects.flatMap((effect) =>
95
+ effect.type === 'footer' ? ['footer'] : effect.type === 'send' && effect.text.includes('┣') ? ['tool'] : [],
96
+ )
97
+ expect({ coordinatorIdle: shown(idle, true), coordinatorNoText: shown(silent, true), threadIdle: shown(idle, false) }).toMatchInlineSnapshot(`
74
98
  {
75
- "sent": [
76
- "-# *using openai/gpt-6-luna low ⋅ build*",
77
- "Visible **reply**",
78
- "-# *project ⋅ main ⋅ 17s ⋅ 6% ⋅ gpt-6-luna*",
99
+ "coordinatorIdle": [
100
+ "tool",
101
+ "tool",
102
+ "tool",
103
+ "tool",
104
+ "tool",
79
105
  ],
80
- "silent": [
81
- "-# *using openai/gpt-6-luna low ⋅ build*",
82
- "Done. \`tmp-events/hello.txt\` contains \`hello world\`.",
83
- "-# *project ⋅ main ⋅ 17s ⋅ 6% ⋅ gpt-6-luna*",
106
+ "coordinatorNoText": [
107
+ "tool",
108
+ "tool",
109
+ "tool",
110
+ "tool",
111
+ "tool",
112
+ ],
113
+ "threadIdle": [
114
+ "tool",
115
+ "tool",
116
+ "tool",
117
+ "tool",
118
+ "tool",
119
+ "footer",
84
120
  ],
85
121
  }
86
122
  `)
@@ -100,11 +136,27 @@ test('kimaki sleep in a turn makes its footer silent, the next turn notifies aga
100
136
  `)
101
137
  })
102
138
 
139
+ test('root model and agent switches after the banner show as subtext lines', () => {
140
+ const events = loadFixture('switch-model.events.jsonl')
141
+ const switches = events.filter((event) => event.type === 'session.model.selected' || event.type === 'session.agent.selected')
142
+ // Before the first step the banner shows the model and agent; a re-select of the same model shows nothing.
143
+ const sameModel = switches.flatMap((event) => (event.type === 'session.model.selected' ? [{ ...event, data: { ...event.data, previous: event.data.model } }] : []))
144
+ const created = events.findIndex((event) => event.type === 'session.created')
145
+ const lines = effectLines(replay({ events: [...events.slice(0, created + 1), ...switches, ...events.slice(created + 1), ...sameModel] }).effects)
146
+ expect(lines.filter((line) => /Switched|using/.test(line))).toMatchInlineSnapshot(`
147
+ [
148
+ "-# *using openai/gpt-6-luna low ⋅ build*",
149
+ "-# Switched model to GPT 6 Luna (medium)",
150
+ "-# Switched agent to Plan",
151
+ ]
152
+ `)
153
+ })
154
+
103
155
  test('execution failure shows the error and no footer, interrupt shows nothing', () => {
104
156
  const events = loadFixture('abort.events.jsonl')
105
157
  const sessionID = rootSessionId(events)
106
158
  const started = events.find((event) => event.type === 'session.execution.started')!
107
- const view = emptyView({ sessionId: sessionID, channelId: 'channel', directory: '/project', isNew: false })
159
+ const view = emptyView({ sessionId: sessionID, channelId: 'channel', directory: '/project', isNew: false, coordinator: false })
108
160
  const busy = reduce({ view, event: started, prefs: DEFAULT_PREFS }).view
109
161
  const failed = reduce({
110
162
  view: busy,
@@ -138,7 +190,8 @@ test('execution failure shows the error and no footer, interrupt shows nothing',
138
190
  expect(replay({ events, view }).effects).toMatchInlineSnapshot(`
139
191
  [
140
192
  {
141
- "text": "» **user:** Run \`sleep 30 && echo never\` with bash, then summarize.",
193
+ "echo": true,
194
+ "text": "-# » **user:** Run \`sleep 30 && echo never\` with bash, then summarize.",
142
195
  "type": "send",
143
196
  },
144
197
  {
@@ -159,7 +212,8 @@ test('execution failure shows the error and no footer, interrupt shows nothing',
159
212
  "type": "viewed",
160
213
  },
161
214
  {
162
- "text": "» **user:** Say only: resumed after abort.",
215
+ "echo": true,
216
+ "text": "-# » **user:** Say only: resumed after abort.",
163
217
  "type": "send",
164
218
  },
165
219
  {
@@ -168,6 +222,7 @@ test('execution failure shows the error and no footer, interrupt shows nothing',
168
222
  },
169
223
  {
170
224
  "blankLineBefore": false,
225
+ "replyTo": null,
171
226
  "text": "resumed after abort",
172
227
  "type": "markdown",
173
228
  },
@@ -177,6 +232,7 @@ test('execution failure shows the error and no footer, interrupt shows nothing',
177
232
  },
178
233
  {
179
234
  "agent": "build",
235
+ "channelId": "channel",
180
236
  "contextPercent": 6,
181
237
  "directory": "/project",
182
238
  "durationMs": 1387,
@@ -211,7 +267,7 @@ test('only these event types produce effects for the root session', () => {
211
267
  for (const file of files) {
212
268
  const events = loadFixture(file)
213
269
  const sessionId = rootSessionId(events)
214
- let view = emptyView({ sessionId, channelId: 'channel', directory: '/project', isNew: true })
270
+ let view = emptyView({ sessionId, channelId: 'channel', directory: '/project', isNew: true, coordinator: false })
215
271
  for (const event of events) {
216
272
  const result = reduce({ view, event, prefs: DEFAULT_PREFS })
217
273
  view = result.view
@@ -220,11 +276,13 @@ test('only these event types produce effects for the root session', () => {
220
276
  }
221
277
  expect([...producing].sort()).toMatchInlineSnapshot(`
222
278
  [
279
+ "session.agent.selected",
223
280
  "session.execution.interrupted",
224
281
  "session.execution.started",
225
282
  "session.execution.succeeded",
226
283
  "session.inbox.delivered",
227
284
  "session.inbox.enqueued",
285
+ "session.model.selected",
228
286
  "session.shell.ended",
229
287
  "session.shell.started",
230
288
  "session.step.started",
@@ -244,14 +302,12 @@ test('only these event types produce effects for the root session', () => {
244
302
  "provider.updated",
245
303
  "reference.updated",
246
304
  "server.connected",
247
- "session.agent.selected",
248
305
  "session.compaction.delta",
249
306
  "session.compaction.ended",
250
307
  "session.compaction.started",
251
308
  "session.created",
252
309
  "session.forked",
253
310
  "session.instructions.updated",
254
- "session.model.selected",
255
311
  "session.renamed",
256
312
  "session.step.ended",
257
313
  "session.step.failed",
@@ -277,7 +333,7 @@ test('only these event types produce effects for the root session', () => {
277
333
  test('retry notices are throttled to one per 10s', () => {
278
334
  const events = loadFixture('abort.events.jsonl')
279
335
  const sessionID = rootSessionId(events)
280
- const view = emptyView({ sessionId: sessionID, channelId: 'channel', directory: '/project', isNew: false })
336
+ const view = emptyView({ sessionId: sessionID, channelId: 'channel', directory: '/project', isNew: false, coordinator: false })
281
337
  const retry = (created: number, attempt: number): V2Event => ({
282
338
  id: `evt_retry_${attempt}`,
283
339
  created,
@@ -294,8 +350,8 @@ test('retry notices are throttled to one per 10s', () => {
294
350
  const { effects } = replay({ events: [retry(1_000, 1), retry(6_000, 2), retry(12_000, 3)], view })
295
351
  expect(effectLines(effects)).toMatchInlineSnapshot(`
296
352
  [
297
- "-# ⬦ retrying in 5s (attempt 1): rate limited",
298
- "-# ⬦ retrying in 5s (attempt 3): rate limited",
353
+ "-# retrying in 5s (attempt 1): rate limited",
354
+ "-# retrying in 5s (attempt 3): rate limited",
299
355
  ]
300
356
  `)
301
357
  })
@@ -308,40 +364,133 @@ test('cache miss notice when the cached prefix shrinks, e.g. after the cache TTL
308
364
  const last = events[lastIndex]
309
365
  if (last?.type !== 'session.step.ended') return expect.fail('fixture has no root step.ended')
310
366
  // Same turn, but the last request found nothing in cache 10 minutes later.
311
- const missed = events.map((event, index) =>
312
- index === lastIndex ? { ...last, created: last.created + 600_000, data: { ...last.data, tokens: { ...last.data.tokens, cache: { read: 0, write: 0 } } } } : event,
313
- )
314
- expect(effectLines(replay({ events: missed }).effects).filter((line) => line.includes('cache'))).toMatchInlineSnapshot(`
367
+ const withCache = (cache: { read: number; write: number }, input = last.data.tokens.input) =>
368
+ events.map((event, index) =>
369
+ index === lastIndex ? { ...last, created: last.created + 600_000, data: { ...last.data, tokens: { ...last.data.tokens, input, cache } } } : event,
370
+ )
371
+ const cacheLines = (stream: V2Event[]) => effectLines(replay({ events: stream }).effects).filter((line) => line.includes('cache'))
372
+ // Everything was written again: a full loss.
373
+ expect(cacheLines(withCache({ read: 0, write: 25_000 }, 0))).toMatchInlineSnapshot(`
315
374
  [
316
- "\\n-# ⬦ prompt cache miss: 0 of 25k tokens cached, 10m 1s after the last request",
375
+ "\\n-# prompt cache miss: 0 of 25k tokens cached, 10m 1s after the last request",
317
376
  ]
318
377
  `)
378
+ // Less than half of the prompt was written: a partial miss shows nothing.
379
+ expect(cacheLines(withCache({ read: 0, write: 5_000 }, 20_000))).toEqual([])
319
380
  expect(effectLines(replay({ events }).effects).filter((line) => line.includes('cache'))).toEqual([])
320
381
  })
321
382
 
383
+ test('compaction posts the new context size from the next root step', () => {
384
+ const events = loadFixture('tools.events.jsonl')
385
+ const sessionID = rootSessionId(events)
386
+ const lastIndex = events.findLastIndex((event) => event.type === 'session.step.ended' && event.data.sessionID === sessionID)
387
+ const compacted: V2Event = {
388
+ id: 'evt_compacted',
389
+ created: 0,
390
+ type: 'session.compaction.ended',
391
+ durable: { aggregateID: sessionID, seq: 0, version: 1 },
392
+ data: { sessionID, reason: 'auto', text: '## Objective', recent: '' },
393
+ }
394
+ const withCompaction = [...events.slice(0, lastIndex), compacted, ...events.slice(lastIndex)]
395
+ expect(effectLines(replay({ events: withCompaction }).effects).filter((line) => line.includes('compacted'))).toMatchInlineSnapshot(`
396
+ [
397
+ "\\n-# context compacted to 26k tokens (6%)",
398
+ ]
399
+ `)
400
+ // `/compact` alone: the execution ends with no step after it.
401
+ const end = events.findLastIndex((event) => event.type === 'session.execution.succeeded')
402
+ const manual = [...events.slice(0, end), compacted, ...events.slice(end)]
403
+ expect(effectLines(replay({ events: manual }).effects).filter((line) => line.includes('compacted'))).toMatchInlineSnapshot(`
404
+ [
405
+ "\\n-# context compacted",
406
+ ]
407
+ `)
408
+ })
409
+
322
410
  test('prompts from other clients echo, except the expanded prompt of a command Kimaki ran', () => {
323
411
  const events = loadFixture('queue-plain.events.jsonl')
324
- const echoes = (before: ThreadEvent[]) => effectLines(replay({ events: [...before, ...events] }).effects).filter((line) => line.startsWith('»'))
325
- const command = { type: 'kimaki.command' as const, text: '/review auth', username: 'tommy' }
412
+ // Queued ones show in their ack (`[show queue:...]`), a reply to it marks the run.
413
+ const echoes = (before: ThreadEvent[]) =>
414
+ effectLines(replay({ events: [...before, ...events] }).effects).filter((line) => line.includes('» ') || line.startsWith('[reply to'))
415
+ const command = { type: 'kimaki.command' as const, text: '/review auth', username: 'tommy', messageId: null, echo: null }
326
416
  expect(echoes([])).toMatchInlineSnapshot(`
327
417
  [
328
- "» **user:** Run \`sleep 6 && echo slow-done\` with bash, then say done.",
329
- "» **user:** QUEUED-1: reply with the word apple.",
330
- "» **user:** QUEUED-2: reply with the word cherry.",
418
+ "-# » **user:** Run \`sleep 6 && echo slow-done\` with bash, then say done.",
419
+ "[show queue:msg_0f20347e1001nsJ7W4hOvhBpQb] -# » **user:** QUEUED-1: reply with the word apple.
420
+ -# Queued at position 1. Remove it with /clear-queue position:1",
421
+ "[show queue:msg_0f20347e500196F4o0voD7LGMV] -# » **user:** QUEUED-2: reply with the word cherry.
422
+ -# Queued at position 2. Remove it with /clear-queue position:2",
423
+ "[edit queue:msg_0f20347e1001nsJ7W4hOvhBpQb] -# » **user:** QUEUED-1: reply with the word apple.",
424
+ "[reply to queue:msg_0f20347e1001nsJ7W4hOvhBpQb] -# Queued message sent",
425
+ "[edit queue:msg_0f20347e500196F4o0voD7LGMV] -# » **user:** QUEUED-2: reply with the word cherry.",
426
+ "[reply to queue:msg_0f20347e500196F4o0voD7LGMV] -# Queued message sent",
331
427
  ]
332
428
  `)
333
- // The first prompt is the command's template: hidden. The queued ones still echo.
429
+ // The first prompt is the command's template: hidden. The queued ones still show.
334
430
  expect(echoes([command])).toMatchInlineSnapshot(`
335
431
  [
336
- "» **user:** QUEUED-1: reply with the word apple.",
337
- "» **user:** QUEUED-2: reply with the word cherry.",
432
+ "[show queue:msg_0f20347e1001nsJ7W4hOvhBpQb] -# » **user:** QUEUED-1: reply with the word apple.
433
+ -# Queued at position 1. Remove it with /clear-queue position:1",
434
+ "[show queue:msg_0f20347e500196F4o0voD7LGMV] -# » **user:** QUEUED-2: reply with the word cherry.
435
+ -# Queued at position 2. Remove it with /clear-queue position:2",
436
+ "[edit queue:msg_0f20347e1001nsJ7W4hOvhBpQb] -# » **user:** QUEUED-1: reply with the word apple.",
437
+ "[reply to queue:msg_0f20347e1001nsJ7W4hOvhBpQb] -# Queued message sent",
438
+ "[edit queue:msg_0f20347e500196F4o0voD7LGMV] -# » **user:** QUEUED-2: reply with the word cherry.",
439
+ "[reply to queue:msg_0f20347e500196F4o0voD7LGMV] -# Queued message sent",
338
440
  ]
339
441
  `)
442
+ // A command sent with `kimaki send` has no Discord message: its echo stands in for the template.
443
+ expect(echoes([{ ...command, echo: '-# » **CLI:** /review auth' }])[0]).toMatchInlineSnapshot(`"-# » **CLI:** /review auth"`)
340
444
  // A failed command leaves nothing pending.
341
445
  expect(echoes([command, { type: 'kimaki.command-failed' }])).toEqual(echoes([]))
342
446
  })
343
447
 
344
- test('snapshot closes the ack of an item promoted while disconnected, without an echo', () => {
448
+ test('coordinator: a prompt reply goes to its Discord message, a queued one gets "Queued message sent" even when it answers IDLE', () => {
449
+ // The fixture's prompts get Discord metadata, like prompt.ts writes it: messages 101, 102, 103.
450
+ const withDiscord = (threadId: string) => {
451
+ let next = 101
452
+ return loadFixture('queue-plain.events.jsonl').map((event): V2Event => {
453
+ if (event.type !== 'session.inbox.enqueued' || event.data.item.type !== 'user') return event
454
+ const discord = { messageId: String(next++), threadId, username: 'tommy' }
455
+ const item = { ...event.data.item, payload: { ...event.data.item.payload, metadata: { discord } } }
456
+ return { ...event, data: { ...event.data, item } }
457
+ })
458
+ }
459
+ // replay() binds the view to channel `channel`: a prompt whose conversation is that channel is a coordinator prompt.
460
+ expect(effectLines(replay({ events: withDiscord('channel') }).effects)).toMatchInlineSnapshot(`
461
+ [
462
+ "[typing on]",
463
+ "-# *using openai/gpt-6-luna low ⋅ build*",
464
+ "-# ┣ shell _sleep 6 && echo slow-done_",
465
+ "[show queue:msg_0f20347e1001nsJ7W4hOvhBpQb reply to 102] -# Queued at position 1. Delete the original message to remove it, or use /clear-queue position:1",
466
+ "[show queue:msg_0f20347e500196F4o0voD7LGMV reply to 103] -# Queued at position 2. Delete the original message to remove it, or use /clear-queue position:2",
467
+ "[reply to 101] Done.",
468
+ "[silent] -# *main ⋅ 10s ⋅ 6% ⋅ gpt-6-luna*",
469
+ "[edit queue:msg_0f20347e1001nsJ7W4hOvhBpQb] -# Queued",
470
+ "[reply to 102] -# Queued message sent",
471
+ "apple",
472
+ "[silent] -# *main ⋅ 2s ⋅ 6% ⋅ gpt-6-luna*",
473
+ "[edit queue:msg_0f20347e500196F4o0voD7LGMV] -# Queued",
474
+ "[reply to 103] -# Queued message sent",
475
+ "cherry",
476
+ "[typing off]",
477
+ "-# *main ⋅ 2s ⋅ 6% ⋅ gpt-6-luna*",
478
+ ]
479
+ `)
480
+ // A coordinator that starts a worker answers IDLE: the queued prompt still shows that it started.
481
+ const idle = withDiscord('channel').map((event) => (event.type === 'session.text.ended' && event.data.text.trim() === 'apple' ? { ...event, data: { ...event.data, text: 'IDLE' } } : event))
482
+ expect(effectLines(replay({ events: idle }).effects).filter((line) => line.includes('queue:msg_0f20347e1001') || line.startsWith('[reply to 102]'))).toMatchInlineSnapshot(`
483
+ [
484
+ "[show queue:msg_0f20347e1001nsJ7W4hOvhBpQb reply to 102] -# Queued at position 1. Delete the original message to remove it, or use /clear-queue position:1",
485
+ "[edit queue:msg_0f20347e1001nsJ7W4hOvhBpQb] -# Queued",
486
+ "[reply to 102] -# Queued message sent",
487
+ ]
488
+ `)
489
+ const thread = replay({ events: withDiscord('thread') }).effects
490
+ expect(thread.filter((effect) => effect.type === 'markdown').map((effect) => effect.replyTo)).toEqual([null, null, null])
491
+ })
492
+
493
+ test('snapshot marks an item promoted while disconnected as started, without an echo', () => {
345
494
  const events = loadFixture('queue-plain.events.jsonl')
346
495
  const index = events.findIndex((event) => event.type === 'session.inbox.enqueued' && event.data.item.delivery === 'queue')
347
496
  const enqueued = events[index]
@@ -363,7 +512,8 @@ test('snapshot closes the ack of an item promoted while disconnected, without an
363
512
  const hydrated = reduce({ view, event: snapshot, prefs: DEFAULT_PREFS })
364
513
  expect(effectLines(hydrated.effects)).toMatchInlineSnapshot(`
365
514
  [
366
- "[edit queue:msg_0f20347e1001nsJ7W4hOvhBpQb] -# Queued message sent",
515
+ "[edit queue:msg_0f20347e1001nsJ7W4hOvhBpQb] -# » **user:** QUEUED-1: reply with the word apple.",
516
+ "[reply to queue:msg_0f20347e1001nsJ7W4hOvhBpQb] -# Queued message sent",
367
517
  ]
368
518
  `)
369
519
  expect(hydrated.effects.map((effect) => effect.type)).toEqual(['edit'])
@@ -374,7 +524,7 @@ test('snapshot closes the ack of an item promoted while disconnected, without an
374
524
  test('execute: final metadata shows inner calls whose progress was missed, then failures', () => {
375
525
  const events = loadFixture('abort.events.jsonl')
376
526
  const sessionID = rootSessionId(events)
377
- const view = emptyView({ sessionId: sessionID, channelId: 'channel', directory: '/project', isNew: false })
527
+ const view = emptyView({ sessionId: sessionID, channelId: 'channel', directory: '/project', isNew: false, coordinator: false })
378
528
  const base = { created: 1_000 }
379
529
  const durable = <V extends number>(version: V) => ({ aggregateID: sessionID, seq: 1, version })
380
530
  const call = { sessionID, assistantMessageID: 'msg_a', id: 'call_1' }