kimaki 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/assets/fonts/Geist-Bold.ttf +0 -0
  2. package/assets/fonts/Geist-Regular.ttf +0 -0
  3. package/assets/fonts/GeistMono-Regular.ttf +0 -0
  4. package/assets/fonts/OFL.txt +92 -0
  5. package/dist/agent-ui.e2e.test.js +27 -13
  6. package/dist/agent-ui.e2e.test.js.map +1 -1
  7. package/dist/agent-ui.js +2 -1
  8. package/dist/agent-ui.js.map +1 -1
  9. package/dist/analytics.e2e.test.js +1 -1
  10. package/dist/analytics.e2e.test.js.map +1 -1
  11. package/dist/autostart.d.ts +4 -0
  12. package/dist/autostart.js +55 -10
  13. package/dist/autostart.js.map +1 -1
  14. package/dist/bot.d.ts +10 -1
  15. package/dist/bot.js +14 -6
  16. package/dist/bot.js.map +1 -1
  17. package/dist/btw.e2e.test.js +3 -3
  18. package/dist/builtin-skills.d.ts +1 -1
  19. package/dist/builtin-skills.js +1 -1
  20. package/dist/builtin-skills.js.map +1 -1
  21. package/dist/builtin-skills.test.js +3 -4
  22. package/dist/builtin-skills.test.js.map +1 -1
  23. package/dist/catalog-refresh.e2e.test.js +2 -2
  24. package/dist/cli/bot.js +25 -14
  25. package/dist/cli/bot.js.map +1 -1
  26. package/dist/cli/discord.js +32 -6
  27. package/dist/cli/discord.js.map +1 -1
  28. package/dist/cli/schedule.js +23 -9
  29. package/dist/cli/schedule.js.map +1 -1
  30. package/dist/cli/session.js +37 -12
  31. package/dist/cli/session.js.map +1 -1
  32. package/dist/cli/shared.d.ts +5 -3
  33. package/dist/cli/shared.js +45 -10
  34. package/dist/cli/shared.js.map +1 -1
  35. package/dist/cli-discord.e2e.test.js +13 -8
  36. package/dist/cli-discord.e2e.test.js.map +1 -1
  37. package/dist/cli-lock.e2e.test.js +87 -20
  38. package/dist/cli-lock.e2e.test.js.map +1 -1
  39. package/dist/cli-read.e2e.test.js +18 -9
  40. package/dist/cli-read.e2e.test.js.map +1 -1
  41. package/dist/cli-sessions.e2e.test.js +4 -3
  42. package/dist/cli-sessions.e2e.test.js.map +1 -1
  43. package/dist/cli.js +2 -0
  44. package/dist/cli.js.map +1 -1
  45. package/dist/commands/preference-commands.d.ts +4 -0
  46. package/dist/commands/preference-commands.js +38 -4
  47. package/dist/commands/preference-commands.js.map +1 -1
  48. package/dist/commands/session-commands.js +11 -8
  49. package/dist/commands/session-commands.js.map +1 -1
  50. package/dist/commands/thread-commands.js +94 -24
  51. package/dist/commands/thread-commands.js.map +1 -1
  52. package/dist/commands/worktree-commands.d.ts +4 -5
  53. package/dist/commands/worktree-commands.js +8 -7
  54. package/dist/commands/worktree-commands.js.map +1 -1
  55. package/dist/commands.e2e.test.js +25 -21
  56. package/dist/commands.e2e.test.js.map +1 -1
  57. package/dist/coordinator-agents.e2e.test.js +135 -0
  58. package/dist/coordinator-agents.e2e.test.js.map +1 -0
  59. package/dist/coordinator-idle.e2e.test.js +100 -0
  60. package/dist/coordinator-idle.e2e.test.js.map +1 -0
  61. package/dist/coordinator-queue.e2e.test.js +244 -0
  62. package/dist/coordinator-queue.e2e.test.js.map +1 -0
  63. package/dist/coordinator-warmup.e2e.test.d.ts +1 -0
  64. package/dist/coordinator-warmup.e2e.test.js +131 -0
  65. package/dist/coordinator-warmup.e2e.test.js.map +1 -0
  66. package/dist/coordinator.d.ts +6 -0
  67. package/dist/coordinator.e2e.test.d.ts +1 -0
  68. package/dist/coordinator.e2e.test.js +505 -0
  69. package/dist/coordinator.e2e.test.js.map +1 -0
  70. package/dist/coordinator.js +164 -0
  71. package/dist/coordinator.js.map +1 -0
  72. package/dist/db.d.ts +75 -0
  73. package/dist/db.test.js +1 -0
  74. package/dist/db.test.js.map +1 -1
  75. package/dist/diagram.d.ts +11 -0
  76. package/dist/diagram.js +232 -0
  77. package/dist/diagram.js.map +1 -0
  78. package/dist/diagram.test.d.ts +1 -0
  79. package/dist/diagram.test.js +52 -0
  80. package/dist/diagram.test.js.map +1 -0
  81. package/dist/duration.js +13 -5
  82. package/dist/duration.js.map +1 -1
  83. package/dist/effects.d.ts +6 -1
  84. package/dist/effects.js +97 -26
  85. package/dist/effects.js.map +1 -1
  86. package/dist/event-loop.d.ts +1 -1
  87. package/dist/event-loop.js +73 -43
  88. package/dist/event-loop.js.map +1 -1
  89. package/dist/format-parts.d.ts +21 -4
  90. package/dist/format-parts.js +40 -23
  91. package/dist/format-parts.js.map +1 -1
  92. package/dist/gateway-replay.e2e.test.js +4 -4
  93. package/dist/harness.e2e.test.js +2 -2
  94. package/dist/harness.e2e.test.js.map +1 -1
  95. package/dist/hello-thread.e2e.test.js +56 -5
  96. package/dist/hello-thread.e2e.test.js.map +1 -1
  97. package/dist/ingress.js +68 -13
  98. package/dist/ingress.js.map +1 -1
  99. package/dist/interaction-context.d.ts +4 -4
  100. package/dist/interaction-context.js +9 -7
  101. package/dist/interaction-context.js.map +1 -1
  102. package/dist/interrupt.e2e.test.js +8 -5
  103. package/dist/interrupt.e2e.test.js.map +1 -1
  104. package/dist/legacy.e2e.test.js +1 -1
  105. package/dist/lock-routes.d.ts +68 -16
  106. package/dist/lock-routes.js +68 -8
  107. package/dist/lock-routes.js.map +1 -1
  108. package/dist/lock-server.d.ts +3 -2
  109. package/dist/lock-server.js +23 -7
  110. package/dist/lock-server.js.map +1 -1
  111. package/dist/main.d.ts +4 -2
  112. package/dist/main.js +13 -3
  113. package/dist/main.js.map +1 -1
  114. package/dist/markdown/markdown.test.js +4 -4
  115. package/dist/markdown/render-markdown.js +6 -5
  116. package/dist/markdown/render-markdown.js.map +1 -1
  117. package/dist/onboarding-cli.e2e.test.js +7 -6
  118. package/dist/onboarding-cli.e2e.test.js.map +1 -1
  119. package/dist/onboarding.d.ts +3 -10
  120. package/dist/onboarding.e2e.test.js +8 -10
  121. package/dist/onboarding.e2e.test.js.map +1 -1
  122. package/dist/onboarding.js +14 -24
  123. package/dist/onboarding.js.map +1 -1
  124. package/dist/permissions.e2e.test.js +3 -3
  125. package/dist/plugin/coordinator.d.ts +4 -0
  126. package/dist/plugin/coordinator.js +389 -0
  127. package/dist/plugin/coordinator.js.map +1 -0
  128. package/dist/plugin/index.js +76 -17
  129. package/dist/plugin/index.js.map +1 -1
  130. package/dist/plugin.e2e.test.js +1 -1
  131. package/dist/preference-commands.e2e.test.js +103 -24
  132. package/dist/preference-commands.e2e.test.js.map +1 -1
  133. package/dist/prompt.d.ts +21 -8
  134. package/dist/prompt.js +64 -48
  135. package/dist/prompt.js.map +1 -1
  136. package/dist/questions.e2e.test.js +13 -13
  137. package/dist/queue-sources.e2e.test.d.ts +1 -0
  138. package/dist/queue-sources.e2e.test.js +168 -0
  139. package/dist/queue-sources.e2e.test.js.map +1 -0
  140. package/dist/queue.d.ts +9 -1
  141. package/dist/queue.e2e.test.js +48 -30
  142. package/dist/queue.e2e.test.js.map +1 -1
  143. package/dist/queue.js +97 -24
  144. package/dist/queue.js.map +1 -1
  145. package/dist/queue.test.d.ts +1 -0
  146. package/dist/queue.test.js +57 -0
  147. package/dist/queue.test.js.map +1 -0
  148. package/dist/remote-send.e2e.test.js +6 -6
  149. package/dist/remote-send.js +2 -1
  150. package/dist/remote-send.js.map +1 -1
  151. package/dist/renderer.e2e.test.js +7 -7
  152. package/dist/restart.e2e.test.js +2 -2
  153. package/dist/routines.e2e.test.js +2 -2
  154. package/dist/scheduler.d.ts +1 -1
  155. package/dist/scheduler.e2e.test.js +43 -20
  156. package/dist/scheduler.e2e.test.js.map +1 -1
  157. package/dist/scheduler.js +4 -2
  158. package/dist/scheduler.js.map +1 -1
  159. package/dist/schema-sql.d.ts +1 -1
  160. package/dist/schema-sql.js +1 -1
  161. package/dist/schema-sql.js.map +1 -1
  162. package/dist/schema.d.ts +146 -0
  163. package/dist/schema.js +12 -0
  164. package/dist/schema.js.map +1 -1
  165. package/dist/session-commands.e2e.test.js +36 -11
  166. package/dist/session-commands.e2e.test.js.map +1 -1
  167. package/dist/session-events.d.ts +19 -7
  168. package/dist/session-events.js +166 -22
  169. package/dist/session-events.js.map +1 -1
  170. package/dist/session-ops.e2e.test.js +88 -11
  171. package/dist/session-ops.e2e.test.js.map +1 -1
  172. package/dist/sessions.d.ts +41 -18
  173. package/dist/sessions.js +174 -63
  174. package/dist/sessions.js.map +1 -1
  175. package/dist/shell.e2e.test.js +4 -4
  176. package/dist/slash-commands.js +12 -6
  177. package/dist/slash-commands.js.map +1 -1
  178. package/dist/slash-commands.test.js +2 -0
  179. package/dist/slash-commands.test.js.map +1 -1
  180. package/dist/sleep.e2e.test.js +29 -14
  181. package/dist/sleep.e2e.test.js.map +1 -1
  182. package/dist/sleeps.d.ts +1 -0
  183. package/dist/sleeps.js +21 -3
  184. package/dist/sleeps.js.map +1 -1
  185. package/dist/store.d.ts +1 -0
  186. package/dist/store.js +3 -2
  187. package/dist/store.js.map +1 -1
  188. package/dist/system-prompt.d.ts +3 -1
  189. package/dist/system-prompt.js +138 -64
  190. package/dist/system-prompt.js.map +1 -1
  191. package/dist/test/generate-log-plugin/index.js +15 -0
  192. package/dist/test/generate-log-plugin/index.js.map +1 -0
  193. package/dist/test/harness.d.ts +20 -1
  194. package/dist/test/harness.js +51 -10
  195. package/dist/test/harness.js.map +1 -1
  196. package/dist/test/replay.js +11 -7
  197. package/dist/test/replay.js.map +1 -1
  198. package/dist/thread-reducer.d.ts +29 -3
  199. package/dist/thread-reducer.fixtures.test.js +58 -51
  200. package/dist/thread-reducer.fixtures.test.js.map +1 -1
  201. package/dist/thread-reducer.js +112 -25
  202. package/dist/thread-reducer.js.map +1 -1
  203. package/dist/thread-reducer.test.js +197 -24
  204. package/dist/thread-reducer.test.js.map +1 -1
  205. package/dist/voice-attachments.e2e.test.js +108 -18
  206. package/dist/voice-attachments.e2e.test.js.map +1 -1
  207. package/dist/voice-call.e2e.test.js +9 -9
  208. package/dist/voice-call.js +6 -6
  209. package/dist/voice-call.js.map +1 -1
  210. package/dist/voice-openai.e2e.test.js +59 -23
  211. package/dist/voice-openai.e2e.test.js.map +1 -1
  212. package/dist/voice.js +80 -24
  213. package/dist/voice.js.map +1 -1
  214. package/dist/working-directory.e2e.test.js +11 -11
  215. package/dist/working-directory.e2e.test.js.map +1 -1
  216. package/package.json +12 -6
  217. package/src/agent-ui.e2e.test.ts +27 -13
  218. package/src/agent-ui.ts +2 -1
  219. package/src/analytics.e2e.test.ts +1 -1
  220. package/src/autostart.ts +55 -10
  221. package/src/bot.ts +29 -5
  222. package/src/btw.e2e.test.ts +3 -3
  223. package/src/builtin-skills.test.ts +3 -4
  224. package/src/builtin-skills.ts +1 -1
  225. package/src/catalog-refresh.e2e.test.ts +2 -2
  226. package/src/cli/bot.ts +23 -14
  227. package/src/cli/discord.ts +30 -6
  228. package/src/cli/schedule.ts +23 -9
  229. package/src/cli/session.ts +35 -12
  230. package/src/cli/shared.ts +43 -8
  231. package/src/cli-discord.e2e.test.ts +13 -8
  232. package/src/cli-lock.e2e.test.ts +88 -20
  233. package/src/cli-read.e2e.test.ts +17 -8
  234. package/src/cli-sessions.e2e.test.ts +4 -3
  235. package/src/cli.ts +2 -0
  236. package/src/commands/preference-commands.ts +38 -4
  237. package/src/commands/session-commands.ts +12 -7
  238. package/src/commands/thread-commands.ts +92 -24
  239. package/src/commands/worktree-commands.ts +10 -9
  240. package/src/commands.e2e.test.ts +25 -21
  241. package/src/coordinator-agents.e2e.test.ts +168 -0
  242. package/src/coordinator-idle.e2e.test.ts +122 -0
  243. package/src/coordinator-queue.e2e.test.ts +279 -0
  244. package/src/coordinator-warmup.e2e.test.ts +158 -0
  245. package/src/coordinator.e2e.test.ts +545 -0
  246. package/src/coordinator.ts +168 -0
  247. package/src/db.test.ts +1 -0
  248. package/src/diagram.test.ts +56 -0
  249. package/src/diagram.ts +243 -0
  250. package/src/duration.ts +13 -5
  251. package/src/effects.ts +99 -27
  252. package/src/event-loop.ts +71 -42
  253. package/src/format-parts.ts +53 -22
  254. package/src/gateway-replay.e2e.test.ts +4 -4
  255. package/src/harness.e2e.test.ts +2 -2
  256. package/src/hello-thread.e2e.test.ts +59 -5
  257. package/src/ingress.ts +62 -12
  258. package/src/interaction-context.ts +12 -11
  259. package/src/interrupt.e2e.test.ts +8 -5
  260. package/src/legacy.e2e.test.ts +1 -1
  261. package/src/lock-routes.ts +62 -8
  262. package/src/lock-server.ts +23 -7
  263. package/src/main.ts +22 -7
  264. package/src/markdown/markdown.test.ts +4 -4
  265. package/src/markdown/render-markdown.ts +6 -5
  266. package/src/onboarding-cli.e2e.test.ts +7 -6
  267. package/src/onboarding.e2e.test.ts +8 -10
  268. package/src/onboarding.ts +13 -29
  269. package/src/permissions.e2e.test.ts +3 -3
  270. package/src/plugin/coordinator.ts +401 -0
  271. package/src/plugin/index.ts +68 -15
  272. package/src/plugin.e2e.test.ts +1 -1
  273. package/src/preference-commands.e2e.test.ts +103 -23
  274. package/src/prompt.ts +87 -43
  275. package/src/questions.e2e.test.ts +13 -13
  276. package/src/queue-sources.e2e.test.ts +197 -0
  277. package/src/queue.e2e.test.ts +48 -30
  278. package/src/queue.test.ts +59 -0
  279. package/src/queue.ts +104 -24
  280. package/src/remote-send.e2e.test.ts +6 -6
  281. package/src/remote-send.ts +2 -1
  282. package/src/renderer.e2e.test.ts +7 -7
  283. package/src/restart.e2e.test.ts +2 -2
  284. package/src/routines.e2e.test.ts +2 -2
  285. package/src/scheduler.e2e.test.ts +45 -19
  286. package/src/scheduler.ts +4 -2
  287. package/src/schema-sql.ts +1 -1
  288. package/src/schema.ts +13 -0
  289. package/src/session-commands.e2e.test.ts +38 -11
  290. package/src/session-events.ts +160 -21
  291. package/src/session-ops.e2e.test.ts +90 -10
  292. package/src/sessions.ts +214 -64
  293. package/src/shell.e2e.test.ts +4 -4
  294. package/src/slash-commands.test.ts +2 -0
  295. package/src/slash-commands.ts +12 -6
  296. package/src/sleep.e2e.test.ts +30 -13
  297. package/src/sleeps.ts +20 -3
  298. package/src/store.ts +5 -2
  299. package/src/system-prompt.ts +143 -63
  300. package/src/test/generate-log-plugin/index.ts +16 -0
  301. package/src/test/harness.ts +77 -14
  302. package/src/test/replay.ts +12 -6
  303. package/src/thread-reducer.fixtures.test.ts +58 -51
  304. package/src/thread-reducer.test.ts +207 -26
  305. package/src/thread-reducer.ts +138 -37
  306. package/src/voice-attachments.e2e.test.ts +110 -18
  307. package/src/voice-call.e2e.test.ts +9 -9
  308. package/src/voice-call.ts +6 -6
  309. package/src/voice-openai.e2e.test.ts +64 -24
  310. package/src/voice.ts +101 -26
  311. package/src/working-directory.e2e.test.ts +11 -11
  312. package/dist/message-tool.e2e.test.js +0 -39
  313. package/dist/message-tool.e2e.test.js.map +0 -1
  314. package/dist/plugin/read-media.d.ts +0 -23
  315. package/dist/plugin/read-media.js +0 -72
  316. package/dist/plugin/read-media.js.map +0 -1
  317. package/dist/plugin/session-id/index.js +0 -18
  318. package/dist/plugin/session-id/index.js.map +0 -1
  319. package/dist/probe-live.tmp.js +0 -82
  320. package/dist/probe-live.tmp.js.map +0 -1
  321. package/dist/tasks.e2e.test.js +0 -173
  322. package/dist/tasks.e2e.test.js.map +0 -1
  323. /package/dist/{message-tool.e2e.test.d.ts → coordinator-agents.e2e.test.d.ts} +0 -0
  324. /package/dist/{probe-live.tmp.d.ts → coordinator-idle.e2e.test.d.ts} +0 -0
  325. /package/dist/{tasks.e2e.test.d.ts → coordinator-queue.e2e.test.d.ts} +0 -0
  326. /package/dist/{plugin/session-id → test/generate-log-plugin}/index.d.ts +0 -0
@@ -32,6 +32,7 @@ function reply(marker: string, text: string, extra: DeterministicMatcher['when']
32
32
  const matchers: DeterministicMatcher[] = [
33
33
  reply('new-marker', 'saw the file', { rawPromptIncludes: 'file-body-marker' }),
34
34
  reply('first-marker', 'first answer'),
35
+ reply('empty-marker', 'started on the first message'),
35
36
  reply('second-marker', 'second answer', {}, 510),
36
37
  reply('tui-marker', 'tui answer'),
37
38
  reply('resumed-marker', 'resumed answer'),
@@ -137,16 +138,42 @@ test('/new-session starts a thread with attached files and an agent', async () =
137
138
  `)
138
139
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
139
140
  "--- from: assistant (TestBot)
140
- » **tommy:** Read it new-marker
141
- Files: notes.txt
141
+ -# » **tommy:** Read it new-marker
142
+ -# Files: notes.txt
142
143
  -# *using deterministic-provider/deterministic-v2 ⋅ plan*
143
144
  saw the file
144
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ plan*"
145
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ plan*"
145
146
  `)
146
147
  // A slash command thread has no start message: the user must be added explicitly.
147
148
  expect(await discord.channel(thread.id).getMemberIds()).toContain(TEST_USER_ID)
148
149
  })
149
150
 
151
+ test('/new-session without a prompt waits for the first thread message', async () => {
152
+ const { discord, channelId } = twin
153
+ const user = discord.channel(channelId).user(TEST_USER_ID)
154
+ const client = await server.client()
155
+ const thread = await newThread(() =>
156
+ user.runSlashCommand({ name: 'new-session', options: [{ name: 'agent', type: 3, value: 'plan' }] }),
157
+ )
158
+ await waitFor({ label: 'ready note', check: async () => (await discord.thread(thread.id).text()).includes('Send a message') })
159
+ const sessionId = bot.store.getState().roots[thread.id]
160
+ if (!sessionId) throw new Error('thread has no session')
161
+ expect((await client.message.list({ sessionID: sessionId, order: 'asc', limit: 10 })).data).toEqual([])
162
+ await discord.thread(thread.id).user(TEST_USER_ID).sendMessage({ content: 'Go empty-marker' })
163
+ await waitForFooter({ discord, threadId: thread.id })
164
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
165
+ "--- from: assistant (TestBot)
166
+ Send a message to start working.
167
+ --- from: user (tommy)
168
+ Go empty-marker
169
+ --- from: assistant (TestBot)
170
+ -# *using deterministic-provider/deterministic-v2 ⋅ plan*
171
+ started on the first message
172
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ plan*"
173
+ `)
174
+ expect(thread.name).toBe('Kimaki session')
175
+ })
176
+
150
177
  test('/resume binds an existing session to a new thread and moves the binding', async () => {
151
178
  const { discord, channelId } = twin
152
179
  // A session started outside Discord (for example in the TUI).
@@ -180,10 +207,10 @@ test('/resume binds an existing session to a new thread and moves the binding',
180
207
  Go on resumed-marker
181
208
  --- from: assistant (TestBot)
182
209
  resumed answer
183
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
184
- » **user:** Back in the TUI tui-marker
210
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
211
+ -# » **user:** Back in the TUI tui-marker
185
212
  tui answer
186
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
213
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
187
214
  `)
188
215
 
189
216
  // Resuming again moves the session: the old thread stops following it.
@@ -229,7 +256,7 @@ test('/fork forks before a user message into a new thread', async () => {
229
256
  Check fork-check
230
257
  --- from: assistant (TestBot)
231
258
  fork has only the first message
232
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
259
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
233
260
  `)
234
261
  expect(hide(await discord.thread(source.id).text())).toMatchInlineSnapshot(`
235
262
  "--- from: user (tommy)
@@ -237,12 +264,12 @@ test('/fork forks before a user message into a new thread', async () => {
237
264
  --- from: assistant (TestBot)
238
265
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
239
266
  first answer
240
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
267
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
241
268
  --- from: user (tommy)
242
269
  Then second-marker
243
270
  --- from: assistant (TestBot)
244
271
  second answer
245
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
272
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
246
273
  Session forked! Continue in <#THREAD>"
247
274
  `)
248
275
  })
@@ -296,7 +323,7 @@ test('/fork-subagent forks a subagent session into a new thread', async () => {
296
323
  More child-followup
297
324
  --- from: assistant (TestBot)
298
325
  child fork continues
299
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ general*"
326
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ general*"
300
327
  `)
301
328
  })
302
329
 
@@ -321,6 +348,6 @@ test('/resume of a session waiting on a question shows the question in the new t
321
348
  Which color?
322
349
  ✓ _Red_
323
350
  ok
324
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
351
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
325
352
  `)
326
353
  })
@@ -14,7 +14,7 @@
14
14
  import crypto from 'node:crypto'
15
15
  import fs from 'node:fs'
16
16
  import path from 'node:path'
17
- import type { OpenCodeClient } from '@opencode/client'
17
+ import type { OpenCodeClient, V2Event } from '@opencode/client'
18
18
 
19
19
  import type { KimakiDb } from './db.ts'
20
20
  import { ConfigError, DbError, OpenCodeError } from './errors.ts'
@@ -107,7 +107,7 @@ export async function resolveSession({
107
107
  return { threadId: row.thread_id, sessionId: row.session_id }
108
108
  }
109
109
 
110
- type Message = Awaited<ReturnType<OpenCodeClient['message']['list']>>['data'][number]
110
+ export type Message = Awaited<ReturnType<OpenCodeClient['message']['list']>>['data'][number]
111
111
  type Session = Awaited<ReturnType<OpenCodeClient['session']['get']>>
112
112
 
113
113
  // Long enough for most edits, commands and subagent prompts; huge writes stay short.
@@ -127,7 +127,9 @@ function renderMessage(message: Message, options: { thinking?: boolean; verbose?
127
127
  const input = options.verbose ? JSON.stringify(state.input ?? {}) : JSON.stringify(state.input ?? {}).slice(0, TOOL_INPUT_LIMIT)
128
128
  const error = state.status === 'error' ? `\nerror: ${JSON.stringify(state).slice(0, 500)}` : ''
129
129
  const output = options.verbose && state.status === 'completed' ? `\n${JSON.stringify(state.content)}` : ''
130
- return `tool ${part.name} [${state.status}] ${input}${error}${output}`
130
+ // Without --verbose tool outputs are hidden, but uploaded media stays findable.
131
+ const media = options.verbose ? '' : mediaBlock(uploadedMedia(part))
132
+ return `tool ${part.name} [${state.status}] ${input}${error}${output}${media && `\n${media}`}`
131
133
  }
132
134
  return ''
133
135
  })
@@ -135,29 +137,134 @@ function renderMessage(message: Message, options: { thinking?: boolean; verbose?
135
137
  return `${header}\n\n${parts.join('\n\n')}${finish}`
136
138
  }
137
139
 
140
+ export function formatDuration(ms: number): string {
141
+ if (ms < 1_000) return '<1s'
142
+ const seconds = Math.floor(ms / 1_000)
143
+ if (seconds < 60) return `${seconds}s`
144
+ const minutes = Math.floor(seconds / 60)
145
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
146
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
147
+ }
148
+
149
+ export function formatTokens(tokens: number): string {
150
+ return tokens < 1_000 ? String(tokens) : `${Math.round(tokens / 1_000)}k`
151
+ }
152
+
153
+ export type SessionUsage = { contextTokens: number; runMs: number | null; endedAt: number }
154
+
155
+ // Context size of the latest step, length of the latest run and when it ended. Oldest-first input.
156
+ // Agents use it to judge if a follow-up would rewrite a big, expired prompt cache.
157
+ export function sessionUsage(messages: readonly Message[]): SessionUsage | null {
158
+ const lastIndex = messages.findLastIndex((message) => message.type === 'assistant')
159
+ const last = messages[lastIndex]
160
+ if (last?.type !== 'assistant') return null
161
+ const step = messages.findLast((message) => message.type === 'assistant' && !!message.tokens)
162
+ const tokens = step?.type === 'assistant' ? step.tokens : undefined
163
+ const contextTokens = tokens ? tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write : 0
164
+ // A run starts at a user prompt. Synthetic items also arrive mid-run (subagent results), so they count only without one.
165
+ const before = messages.slice(0, lastIndex)
166
+ const start = before.findLast((message) => message.type === 'user') ?? before.findLast((message) => message.type === 'synthetic')
167
+ const endedAt = last.time.completed ?? last.time.created
168
+ return { contextTokens, runMs: start ? endedAt - start.time.created : null, endedAt }
169
+ }
170
+
171
+ function usageLine(usage: SessionUsage | null, now: number): string {
172
+ if (!usage) return ''
173
+ const run = usage.runMs === null ? '' : `, last run took ${formatDuration(usage.runMs)}`
174
+ return `\n\ncontext: ${formatTokens(usage.contextTokens)} tokens${run}, last active ${formatDuration(now - usage.endedAt)} ago (${new Date(usage.endedAt).toISOString()})`
175
+ }
176
+
177
+ // The text of the last assistant message that has text, like OpenCode's
178
+ // subagent completion (session/subagent-completion.ts). Oldest-first input.
179
+ export function finalAnswer(messages: readonly Message[]): string | null {
180
+ for (const message of [...messages].reverse()) {
181
+ if (message.type === 'user') return null
182
+ if (message.type !== 'assistant') continue
183
+ const text = message.content.flatMap((part) => (part.type === 'text' && part.text.trim() ? [part.text.trim()] : [])).join('\n\n')
184
+ if (text) return text
185
+ }
186
+ return null
187
+ }
188
+
189
+ type MessagePart = Extract<Message, { type: 'assistant' }>['content'][number]
190
+
191
+ const UPLOAD_RESULT_PREFIX = '{"uploaded":'
192
+ // `"url":"..."` of each file in that JSON line (upload in prompt.ts). A regex, not zod:
193
+ // this module loads on every CLI start. Discord URLs have no quotes or backslashes.
194
+ const UPLOAD_URL = /"url":"([^"\\]+)"/g
195
+
196
+ // Discord URLs of the files a `kimaki upload-to-discord` call posted, from the result
197
+ // line it printed into the shell output (the system prompt says never to pipe it).
198
+ // The session history holds them, so this needs no Discord read and survives restarts.
199
+ function uploadedMedia(part: MessagePart): string[] {
200
+ if (part.type !== 'tool' || part.state.status !== 'completed') return []
201
+ return part.state.content
202
+ .flatMap((content) => (content.type === 'text' ? content.text.split('\n') : []))
203
+ .filter((line) => line.startsWith(UPLOAD_RESULT_PREFIX))
204
+ .flatMap((line) => [...line.matchAll(UPLOAD_URL)].map((match) => match[1]!))
205
+ }
206
+
207
+ function mediaBlock(urls: readonly string[]): string {
208
+ return urls.length === 0 ? '' : `Media uploaded to Discord:\n${urls.map((url) => `- ${url}`).join('\n')}`
209
+ }
210
+
211
+ // Inputs and the final answer of each turn, no tool calls: what agents read most often.
212
+ function renderAnswers({ session, messages, usage = '' }: { session: Pick<Session, 'id' | 'title' | 'location'>; messages: Message[]; usage?: string }): string {
213
+ const title = `# ${session.title} (${session.id})\n\ndirectory: ${session.location.directory}${usage}`
214
+ const blocks: string[] = []
215
+ let turn: Message[] = []
216
+ const flush = () => {
217
+ const answer = finalAnswer(turn)
218
+ const last = turn.findLast((message) => message.type === 'assistant')
219
+ const media = mediaBlock(turn.flatMap((message) => (message.type === 'assistant' ? message.content.flatMap(uploadedMedia) : [])))
220
+ if (answer && last?.type === 'assistant') blocks.push(`## assistant ${new Date(last.time.created).toISOString()} (${last.agent}, ${last.model.providerID}/${last.model.id})\n\n${answer}${media && `\n\n${media}`}`)
221
+ turn = []
222
+ }
223
+ for (const message of messages) {
224
+ if (message.type === 'assistant') {
225
+ turn.push(message)
226
+ continue
227
+ }
228
+ flush()
229
+ if (message.type === 'user' || message.type === 'synthetic') blocks.push(`## ${message.type} ${new Date(message.time.created).toISOString()}\n\n${message.text}`)
230
+ if (message.type === 'compaction' && message.status !== 'failed') blocks.push(`## compaction ${new Date(message.time.created).toISOString()}\n\n${message.summary}`)
231
+ }
232
+ // A turn without an answer yet (running, or waiting on a question): show its tool calls.
233
+ if (turn.length > 0 && !finalAnswer(turn)) blocks.push(...turn.map((message) => renderMessage(message, {})))
234
+ else flush()
235
+ const more = `Only prompts and final answers are shown. Every tool call and the reasoning: kimaki session read ${session.id} --thinking`
236
+ return [title, ...blocks, more].join('\n\n')
237
+ }
238
+
138
239
  // Oldest first, as markdown. OpenCode returns the newest first.
240
+ // Default: prompts and final answers. `full`: every message with tool calls.
139
241
  export async function readSessionMarkdown({
140
242
  client,
141
243
  sessionId,
244
+ full,
142
245
  thinking,
143
246
  verbose,
144
247
  }: {
145
248
  client: OpenCodeClient
146
249
  sessionId: string
250
+ full?: boolean
147
251
  thinking?: boolean
148
252
  verbose?: boolean
149
253
  }): Promise<OpenCodeError | string> {
150
- const session = await client.session
151
- .get({ sessionID: sessionId })
152
- .catch((e) => new OpenCodeError({ operation: 'session.get', cause: e }))
254
+ const [session, messages] = await Promise.all([
255
+ client.session.get({ sessionID: sessionId }).catch((e) => new OpenCodeError({ operation: 'session.get', cause: e })),
256
+ allMessages({ client, sessionId }),
257
+ ])
153
258
  if (session instanceof Error) return session
154
- const messages = await allMessages({ client, sessionId })
155
259
  if (messages instanceof Error) return messages
156
- return renderSession({ session, messages, thinking, verbose })
260
+ const usage = usageLine(sessionUsage(messages), Date.now())
261
+ if (!full && !thinking && !verbose) return renderAnswers({ session, messages, usage })
262
+ return renderSession({ session, messages, thinking, verbose, usage })
157
263
  }
158
264
 
159
- function renderSession({ session, messages, thinking, verbose }: { session: Pick<Session, 'id' | 'title' | 'location'>; messages: Message[]; thinking?: boolean; verbose?: boolean }): string {
160
- const title = `# ${session.title} (${session.id})\n\ndirectory: ${session.location.directory}`
265
+ // Transcripts pass no usage: their "ago" would be stale on disk.
266
+ function renderSession({ session, messages, thinking, verbose, usage = '' }: { session: Pick<Session, 'id' | 'title' | 'location'>; messages: Message[]; thinking?: boolean; verbose?: boolean; usage?: string }): string {
267
+ const title = `# ${session.title} (${session.id})\n\ndirectory: ${session.location.directory}${usage}`
161
268
  return [title, ...messages.map((message) => renderMessage(message, { thinking, verbose }))].join('\n\n')
162
269
  }
163
270
 
@@ -264,14 +371,34 @@ export function allMessages({ client, sessionId, type }: { client: OpenCodeClien
264
371
  })
265
372
  }
266
373
 
267
- export function allSessions({ client, directory }: { client: OpenCodeClient; directory?: string }) {
374
+ export function allSessions({ client, project }: { client: OpenCodeClient; project?: string }) {
268
375
  return collectPages({
269
376
  operation: 'session.list',
270
- page: (cursor) => client.session.list({ directory, limit: 200, ...(cursor ? { cursor } : { order: 'desc' as const }) }),
377
+ page: (cursor) => client.session.list({ project, limit: 200, ...(cursor ? { cursor } : { order: 'desc' as const }) }),
271
378
  })
272
379
  }
273
380
 
274
- export async function waitForSessionReady({ client, sessionId, signal }: { client: OpenCodeClient; sessionId: string; signal?: AbortSignal }): Promise<OpenCodeError | void> {
381
+ // Why `kimaki session wait` returned for a session. `idle`: no run was in progress
382
+ // when the wait started. `running`: the timeout ended the wait first.
383
+ export type WaitStatus = 'finished' | 'failed' | 'interrupted' | 'idle' | 'question' | 'permission' | 'running'
384
+
385
+ function eventStatus(event: V2Event): { sessionId: string; status: WaitStatus } | null {
386
+ if (event.type === 'form.created') return { sessionId: event.data.form.sessionID, status: 'question' }
387
+ if (event.type === 'permission.asked') return { sessionId: event.data.sessionID, status: 'permission' }
388
+ if (event.type === 'session.execution.succeeded') return { sessionId: event.data.sessionID, status: 'finished' }
389
+ if (event.type === 'session.execution.failed') return { sessionId: event.data.sessionID, status: 'failed' }
390
+ if (event.type === 'session.execution.interrupted') return { sessionId: event.data.sessionID, status: 'interrupted' }
391
+ return null
392
+ }
393
+
394
+ // Waits until every session ended its run or paused for the user, on one event stream.
395
+ // When `signal` ends the wait, the sessions not done yet get `running`.
396
+ export async function waitForSessions({ client, sessionIds, signal }: { client: OpenCodeClient; sessionIds: readonly string[]; signal?: AbortSignal }): Promise<OpenCodeError | Map<string, WaitStatus>> {
397
+ const statuses = new Map<string, WaitStatus>()
398
+ const settle = (sessionId: string, status: WaitStatus) => {
399
+ if (sessionIds.includes(sessionId) && !statuses.has(sessionId)) statuses.set(sessionId, status)
400
+ }
401
+ const done = () => statuses.size === new Set(sessionIds).size
275
402
  const controller = new AbortController()
276
403
  const subscriptionSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
277
404
  const iterator = client.event.subscribe({ signal: subscriptionSignal })[Symbol.asyncIterator]()
@@ -279,27 +406,39 @@ export async function waitForSessionReady({ client, sessionId, signal }: { clien
279
406
  const first = await iterator.next().catch((cause) => new OpenCodeError({ operation: 'event.subscribe', cause }))
280
407
  if (first instanceof Error) return first
281
408
  if (first.done || first.value.type !== 'server.connected') return new OpenCodeError({ operation: 'connect session wait stream' })
409
+ // Subscribed before hydrating, so no run can end unseen in between.
282
410
  const consume = (async () => {
283
411
  while (!subscriptionSignal.aborted) {
284
412
  const next = await iterator.next().catch((cause) => new OpenCodeError({ operation: 'event.subscribe', cause }))
285
413
  if (next instanceof Error) return next
286
414
  if (next.done) return new OpenCodeError({ operation: 'session wait stream ended' })
287
- const event = next.value
288
- if (event.type === 'form.created' && event.data.form.sessionID === sessionId) return
289
- if (event.type === 'permission.asked' && event.data.sessionID === sessionId) return
290
- if ((event.type === 'session.execution.succeeded' || event.type === 'session.execution.failed' || event.type === 'session.execution.interrupted') && event.data.sessionID === sessionId) return
415
+ const ended = eventStatus(next.value)
416
+ if (ended) settle(ended.sessionId, ended.status)
417
+ if (done()) return
291
418
  }
292
419
  return new OpenCodeError({ operation: 'session wait cancelled' })
293
420
  })()
294
421
  const hydrated = await Promise.all([
295
- client.session.active(), client.session.form.list({ sessionID: sessionId }), client.permission.list({ sessionID: sessionId }),
422
+ client.session.active({ signal: subscriptionSignal }),
423
+ Promise.all(sessionIds.map((sessionID) => Promise.all([
424
+ client.session.form.list({ sessionID }, { signal: subscriptionSignal }),
425
+ client.permission.list({ sessionID }, { signal: subscriptionSignal }),
426
+ ]))),
296
427
  ]).catch((cause) => new OpenCodeError({ operation: 'hydrate session wait', cause }))
297
428
  if (hydrated instanceof Error) return hydrated
298
- const [active, forms, permissions] = hydrated
299
- if (!(sessionId in active) || forms.length > 0 || permissions.length > 0) return
429
+ const [active, pending] = hydrated
430
+ for (const [index, sessionId] of sessionIds.entries()) {
431
+ const [forms, permissions] = pending[index] ?? [[], []]
432
+ if (forms.length > 0) settle(sessionId, 'question')
433
+ if (permissions.length > 0) settle(sessionId, 'permission')
434
+ if (!active[sessionId]) settle(sessionId, 'idle')
435
+ }
436
+ if (done()) return
300
437
  return consume
301
438
  })()
302
439
  controller.abort()
303
440
  await iterator.return?.(undefined).catch(() => undefined)
304
- return result
441
+ if (result instanceof Error && !signal?.aborted) return result
442
+ for (const sessionId of sessionIds) settle(sessionId, 'running')
443
+ return statuses
305
444
  }
@@ -16,6 +16,7 @@ import {
16
16
  startTwin,
17
17
  tempDataDir,
18
18
  textParts,
19
+ waitFor,
19
20
  waitForBotMessageContaining,
20
21
  waitForFooter,
21
22
  warmUp,
@@ -32,6 +33,21 @@ const matchers: DeterministicMatcher[] = [
32
33
  then: { parts: textParts('## Objective\n- Test compaction') },
33
34
  },
34
35
  { id: 'usage', priority: 500, when: { latestUserTextIncludes: 'usage-marker' }, then: { parts: textParts('counted') } },
36
+ // A reasoning block that stays open long enough to run /context-usage inside it.
37
+ {
38
+ id: 'slow-thinking',
39
+ priority: 500,
40
+ when: { latestUserTextIncludes: 'thinking-marker' },
41
+ then: {
42
+ parts: [
43
+ { type: 'reasoning-start', id: 'r1' },
44
+ { type: 'reasoning-delta', id: 'r1', delta: 'pondering' },
45
+ { type: 'reasoning-end', id: 'r1' },
46
+ ...textParts('thought it through'),
47
+ ],
48
+ partDelaysMs: [0, 0, 2_500, 0, 0, 0, 0],
49
+ },
50
+ },
35
51
  ...scriptedTurn({
36
52
  marker: 'write-marker',
37
53
  steps: [{ id: 'call-write-1', tool: 'write', input: { path: 'undo.txt', content: 'written by the agent\n' } }],
@@ -75,40 +91,104 @@ async function command(threadId: string, name: string, text: string) {
75
91
  return waitForBotMessageContaining({ discord: twin.discord, threadId, text })
76
92
  }
77
93
 
94
+ // /context-usage status durations depend on timing.
95
+ function stableDurations(text: string) {
96
+ return text.replace(/<1s|\b\d+m \d+s\b|\b\d+s\b/g, 'Ns')
97
+ }
98
+
78
99
  test('/diff with a clean working tree and /context-usage after a turn', async () => {
79
100
  const thread = await newThread('Hello usage-marker')
80
101
  await command(thread.id, 'diff', 'No changes')
81
102
  await command(thread.id, 'context-usage', 'Context usage')
82
- expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
103
+ expect(stableDurations(await twin.discord.thread(thread.id).text())).toMatchInlineSnapshot(`
83
104
  "--- from: user (tommy)
84
105
  Hello usage-marker
85
106
  --- from: assistant (TestBot)
86
107
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
87
108
  counted
88
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
109
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
89
110
  No changes to show
90
111
  **Context usage:** 0%, 2 / 200,000 tokens
91
- **Model:** deterministic-provider/deterministic-v2"
112
+ **Model:** deterministic-provider/deterministic-v2
113
+ **Status:** idle for Ns"
114
+ `)
115
+ })
116
+
117
+ test('/context-usage while the model thinks shows the run and what it does', async () => {
118
+ const { discord, channelId } = twin
119
+ const before = new Set((await discord.channel(channelId).getThreads()).map((thread) => thread.id))
120
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Ponder this thinking-marker' })
121
+ const thread = await discord.channel(channelId).waitForThread({ timeout: 8_000, predicate: (candidate) => !before.has(candidate.id) })
122
+ const client = await server.client()
123
+ await waitFor({
124
+ label: 'open reasoning block',
125
+ check: async () => {
126
+ const sessionId = bot.store.getState().roots[thread.id]
127
+ if (!sessionId) return null
128
+ const messages = await client.message.list({ sessionID: sessionId, type: 'assistant', order: 'desc', limit: 1 })
129
+ return messages.data.some((message) => message.type === 'assistant' && message.content.some((part) => part.type === 'reasoning')) || null
130
+ },
131
+ })
132
+ await command(thread.id, 'context-usage', 'Status:')
133
+ await waitForFooter({ discord, threadId: thread.id })
134
+ expect(stableDurations(await discord.thread(thread.id).text())).toMatchInlineSnapshot(`
135
+ "--- from: user (tommy)
136
+ Ponder this thinking-marker
137
+ --- from: assistant (TestBot)
138
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
139
+ Token usage not available for this session yet
140
+ **Status:** running for Ns, thinking for Ns
141
+ **Last activity:** Ns ago, thinking
142
+ thought it through
143
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
92
144
  `)
93
145
  })
94
146
 
95
147
  test('/compact summarizes the history without a footer', async () => {
96
148
  const thread = await newThread('Something to compact')
97
149
  await command(thread.id, 'compact', 'Compacting')
98
- await waitForBotMessageContaining({ discord: twin.discord, threadId: thread.id, text: 'context compacted' })
150
+ const sessionId = bot.store.getState().roots[thread.id]!
151
+ const client = await server.client()
152
+ await waitFor({
153
+ label: 'compaction message',
154
+ check: async () => (await client.message.list({ sessionID: sessionId, order: 'asc' })).data.some((message) => message.type === 'compaction') || null,
155
+ })
99
156
  expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
100
157
  "--- from: user (tommy)
101
158
  Something to compact
102
159
  --- from: assistant (TestBot)
103
160
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
104
161
  ok
105
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*
106
- Compacting the session context
107
- -# ⬦ context compacted"
162
+ -# *main ⋅ Ns ⋅ deterministic-v2*
163
+ Compacting the session context"
108
164
  `)
165
+ })
166
+
167
+ // Issue #258: the rename's CHANNEL_NAME_CHANGE message holds only the new name.
168
+ test('renaming the thread in Discord sets the session title without a prompt', async () => {
169
+ const thread = await newThread('Hello rename-marker')
109
170
  const sessionId = bot.store.getState().roots[thread.id]!
110
- const messages = await (await server.client()).message.list({ sessionID: sessionId, order: 'asc' })
111
- expect(messages.data.map((message) => message.type)).toContain('compaction')
171
+ const client = await server.client()
172
+ const requests = server.model.requests.length
173
+ await twin.discord.thread(thread.id).user(TEST_USER_ID).renameThread({ name: 'Renamed by hand' })
174
+ await waitFor({
175
+ label: 'session title from the thread name',
176
+ check: async () => (await client.session.get({ sessionID: sessionId })).title === 'Renamed by hand' || null,
177
+ })
178
+ for (let i = 0; i < 10; i++) {
179
+ await new Promise((resolve) => setTimeout(resolve, 20))
180
+ expect(server.model.requests.length).toBe(requests)
181
+ }
182
+ expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
183
+ "--- from: user (tommy)
184
+ Hello rename-marker
185
+ --- from: assistant (TestBot)
186
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
187
+ ok
188
+ -# *main ⋅ Ns ⋅ deterministic-v2*
189
+ --- from: user (tommy)
190
+ [renamed thread to: Renamed by hand]"
191
+ `)
112
192
  })
113
193
 
114
194
  test('/undo hides the last turn but keeps its files, /redo restores the turn', async () => {
@@ -133,7 +213,7 @@ test('/undo hides the last turn but keeps its files, /redo restores the turn', a
133
213
  -# ◼︎ write *undo.txt* (2 lines)
134
214
 
135
215
  wrote undo.txt
136
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
216
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
137
217
  Undone - removed the last turn from the session. File changes were kept
138
218
  Restored - session fully back to its previous state
139
219
  Nothing to redo - no previous undo found"