kimaki 0.27.0 → 0.28.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 (221) hide show
  1. package/dist/agent-model.e2e.test.js +247 -64
  2. package/dist/anthropic-account-identity.js +8 -1
  3. package/dist/anthropic-auth-plugin.js +9 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/bash-tool-schema-plugin.js +32 -0
  6. package/dist/bash-tool-schema-plugin.test.js +49 -0
  7. package/dist/cli-commands/project.js +11 -27
  8. package/dist/cli-commands/send.js +6 -1
  9. package/dist/cli-commands/session.js +139 -82
  10. package/dist/cli-commands/task.js +21 -14
  11. package/dist/cli-parsing.test.js +24 -1
  12. package/dist/cli-runner.js +16 -4
  13. package/dist/cli-send-thread.e2e.test.js +9 -9
  14. package/dist/commands/action-buttons.js +2 -1
  15. package/dist/commands/add-project.js +4 -7
  16. package/dist/commands/agent.js +158 -24
  17. package/dist/commands/ask-question.js +2 -1
  18. package/dist/commands/btw.js +28 -6
  19. package/dist/commands/file-upload.js +2 -1
  20. package/dist/commands/fork-subagent.js +2 -4
  21. package/dist/commands/fork.js +4 -4
  22. package/dist/commands/last-sessions.js +17 -10
  23. package/dist/commands/merge-worktree.js +13 -6
  24. package/dist/commands/model-variant.js +3 -2
  25. package/dist/commands/model.js +4 -3
  26. package/dist/commands/queue.js +3 -2
  27. package/dist/commands/resume.js +4 -4
  28. package/dist/commands/session.js +78 -63
  29. package/dist/cpu-profiler.js +211 -0
  30. package/dist/cpu-profiler.test.js +160 -0
  31. package/dist/database.js +28 -0
  32. package/dist/db.js +11 -0
  33. package/dist/db.test.js +100 -0
  34. package/dist/default-channel-provisioning.test.js +148 -0
  35. package/dist/discord-bot.js +501 -474
  36. package/dist/discord-command-registration.js +16 -9
  37. package/dist/discord-command-registration.test.js +17 -0
  38. package/dist/discord-utils.js +84 -0
  39. package/dist/discord-utils.test.js +135 -1
  40. package/dist/errors.js +5 -0
  41. package/dist/external-directory-permissions.e2e.test.js +11 -7
  42. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  43. package/dist/external-opencode-sync.js +94 -17
  44. package/dist/external-opencode-sync.test.js +206 -0
  45. package/dist/extract-sdk-error-message.test.js +13 -0
  46. package/dist/gateway-proxy.e2e.test.js +18 -18
  47. package/dist/interaction-handler.js +416 -398
  48. package/dist/ipc-tools-plugin.js +12 -7
  49. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +2 -2
  50. package/dist/kimaki-opencode-plugin.js +2 -0
  51. package/dist/logger.js +1 -0
  52. package/dist/message-formatting.js +163 -44
  53. package/dist/message-formatting.test.js +473 -13
  54. package/dist/message-preprocessing.js +105 -1
  55. package/dist/opencode-command.js +28 -0
  56. package/dist/opencode-command.test.js +49 -1
  57. package/dist/opencode-interrupt-plugin.js +4 -0
  58. package/dist/opencode-interrupt-plugin.test.js +24 -0
  59. package/dist/opencode.js +29 -3
  60. package/dist/project-add.test.js +62 -0
  61. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  62. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  63. package/dist/question-text-before-dropdown.e2e.test.js +5 -5
  64. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  65. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  66. package/dist/queue-advanced-footer.e2e.test.js +29 -32
  67. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  68. package/dist/queue-advanced-permissions-typing.e2e.test.js +9 -7
  69. package/dist/queue-advanced-question.e2e.test.js +2 -2
  70. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  71. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  72. package/dist/queue-delete-message.e2e.test.js +3 -3
  73. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  74. package/dist/queue-interrupt-drain.e2e.test.js +20 -20
  75. package/dist/queue-question-select-drain.e2e.test.js +7 -7
  76. package/dist/runtime-lifecycle.e2e.test.js +16 -19
  77. package/dist/schema.js +1 -1
  78. package/dist/session-handler/event-stream-state.js +124 -0
  79. package/dist/session-handler/event-stream-state.test.js +322 -1
  80. package/dist/session-handler/model-utils.js +16 -3
  81. package/dist/session-handler/model-utils.test.js +103 -3
  82. package/dist/session-handler/opencode-session-event-log.js +30 -1
  83. package/dist/session-handler/thread-ingress.test.js +41 -0
  84. package/dist/session-handler/thread-runtime-state.js +2 -0
  85. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  86. package/dist/session-handler/thread-session-runtime.js +340 -135
  87. package/dist/session-search.js +123 -4
  88. package/dist/session-search.test.js +248 -1
  89. package/dist/session-title-rename.test.js +30 -0
  90. package/dist/sleep.e2e.test.js +14 -11
  91. package/dist/system-message.js +45 -12
  92. package/dist/system-message.test.js +117 -10
  93. package/dist/task-runner.js +2 -1
  94. package/dist/task-schedule.js +32 -2
  95. package/dist/task-schedule.test.js +79 -1
  96. package/dist/test-utils.js +17 -9
  97. package/dist/thinking-utils.js +10 -0
  98. package/dist/thinking-utils.test.js +35 -0
  99. package/dist/thread-message-queue.e2e.test.js +65 -54
  100. package/dist/undo-redo.e2e.test.js +17 -9
  101. package/dist/voice-handler.js +21 -4
  102. package/dist/voice-message.e2e.test.js +144 -30
  103. package/dist/voice-routing-input.e2e.test.js +195 -0
  104. package/dist/voice-routing-question.e2e.test.js +125 -0
  105. package/dist/voice.js +28 -8
  106. package/dist/voice.test.js +41 -1
  107. package/dist/worktree-lifecycle.e2e.test.js +30 -30
  108. package/package.json +8 -8
  109. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  110. package/skills/critique/SKILL.md +4 -4
  111. package/skills/errore/SKILL.md +56 -232
  112. package/skills/holocron/SKILL.md +117 -17
  113. package/src/agent-model.e2e.test.ts +293 -66
  114. package/src/anthropic-account-identity.ts +19 -1
  115. package/src/anthropic-auth-plugin.test.ts +19 -0
  116. package/src/anthropic-auth-plugin.ts +11 -16
  117. package/src/bash-tool-schema-plugin.test.ts +55 -0
  118. package/src/bash-tool-schema-plugin.ts +46 -0
  119. package/src/cli-commands/project.ts +14 -35
  120. package/src/cli-commands/send.ts +6 -1
  121. package/src/cli-commands/session.ts +171 -105
  122. package/src/cli-commands/task.ts +22 -15
  123. package/src/cli-parsing.test.ts +34 -1
  124. package/src/cli-runner.ts +16 -3
  125. package/src/cli-send-thread.e2e.test.ts +9 -11
  126. package/src/commands/action-buttons.ts +2 -1
  127. package/src/commands/add-project.ts +4 -8
  128. package/src/commands/agent.ts +199 -23
  129. package/src/commands/ask-question.ts +2 -1
  130. package/src/commands/btw.ts +40 -5
  131. package/src/commands/file-upload.ts +2 -1
  132. package/src/commands/fork-subagent.ts +2 -3
  133. package/src/commands/fork.ts +4 -3
  134. package/src/commands/last-sessions.ts +10 -4
  135. package/src/commands/merge-worktree.ts +13 -6
  136. package/src/commands/model-variant.ts +3 -2
  137. package/src/commands/model.ts +4 -3
  138. package/src/commands/queue.ts +3 -2
  139. package/src/commands/resume.ts +4 -3
  140. package/src/commands/session.ts +94 -70
  141. package/src/cpu-profiler.test.ts +183 -0
  142. package/src/cpu-profiler.ts +293 -0
  143. package/src/database.ts +40 -0
  144. package/src/db.test.ts +105 -0
  145. package/src/db.ts +12 -0
  146. package/src/default-channel-provisioning.test.ts +155 -0
  147. package/src/discord-bot.ts +43 -7
  148. package/src/discord-command-registration.test.ts +20 -0
  149. package/src/discord-command-registration.ts +27 -11
  150. package/src/discord-utils.test.ts +157 -0
  151. package/src/discord-utils.ts +131 -1
  152. package/src/errors.ts +7 -0
  153. package/src/external-directory-permissions.e2e.test.ts +11 -7
  154. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  155. package/src/external-opencode-sync.test.ts +248 -0
  156. package/src/external-opencode-sync.ts +122 -19
  157. package/src/extract-sdk-error-message.test.ts +18 -0
  158. package/src/gateway-proxy.e2e.test.ts +18 -17
  159. package/src/interaction-handler.ts +36 -13
  160. package/src/ipc-tools-plugin.ts +16 -9
  161. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +5 -5
  162. package/src/kimaki-opencode-plugin.ts +2 -0
  163. package/src/logger.ts +1 -0
  164. package/src/message-formatting.test.ts +586 -13
  165. package/src/message-formatting.ts +223 -46
  166. package/src/message-preprocessing.ts +116 -1
  167. package/src/opencode-command.test.ts +59 -0
  168. package/src/opencode-command.ts +38 -0
  169. package/src/opencode-interrupt-plugin.test.ts +32 -0
  170. package/src/opencode-interrupt-plugin.ts +3 -0
  171. package/src/opencode.ts +44 -4
  172. package/src/project-add.test.ts +80 -0
  173. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  174. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  175. package/src/question-text-before-dropdown.e2e.test.ts +5 -5
  176. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  177. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  178. package/src/queue-advanced-footer.e2e.test.ts +30 -31
  179. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  180. package/src/queue-advanced-permissions-typing.e2e.test.ts +9 -7
  181. package/src/queue-advanced-question.e2e.test.ts +2 -2
  182. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  183. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  184. package/src/queue-delete-message.e2e.test.ts +3 -3
  185. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  186. package/src/queue-interrupt-drain.e2e.test.ts +20 -20
  187. package/src/queue-question-select-drain.e2e.test.ts +7 -7
  188. package/src/runtime-lifecycle.e2e.test.ts +16 -18
  189. package/src/schema.sql +1 -1
  190. package/src/schema.ts +1 -1
  191. package/src/session-handler/event-stream-state.test.ts +353 -0
  192. package/src/session-handler/event-stream-state.ts +179 -0
  193. package/src/session-handler/model-utils.test.ts +125 -3
  194. package/src/session-handler/model-utils.ts +36 -4
  195. package/src/session-handler/opencode-session-event-log.ts +31 -1
  196. package/src/session-handler/thread-ingress.test.ts +54 -0
  197. package/src/session-handler/thread-runtime-state.ts +3 -0
  198. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  199. package/src/session-handler/thread-session-runtime.ts +427 -158
  200. package/src/session-search.test.ts +281 -0
  201. package/src/session-search.ts +241 -7
  202. package/src/session-title-rename.test.ts +45 -0
  203. package/src/sleep.e2e.test.ts +14 -11
  204. package/src/store.ts +1 -0
  205. package/src/system-message.test.ts +141 -10
  206. package/src/system-message.ts +66 -11
  207. package/src/task-runner.ts +2 -1
  208. package/src/task-schedule.test.ts +94 -0
  209. package/src/task-schedule.ts +47 -2
  210. package/src/test-utils.ts +21 -10
  211. package/src/thinking-utils.test.ts +46 -0
  212. package/src/thinking-utils.ts +21 -0
  213. package/src/thread-message-queue.e2e.test.ts +65 -53
  214. package/src/undo-redo.e2e.test.ts +17 -9
  215. package/src/voice-handler.ts +23 -4
  216. package/src/voice-message.e2e.test.ts +149 -29
  217. package/src/voice-routing-input.e2e.test.ts +203 -0
  218. package/src/voice-routing-question.e2e.test.ts +131 -0
  219. package/src/voice.test.ts +44 -0
  220. package/src/voice.ts +34 -6
  221. package/src/worktree-lifecycle.e2e.test.ts +30 -30
@@ -20,12 +20,19 @@ import { getDataDir } from './config.js'
20
20
 
21
21
  // Generic message type compatible with both v1 and v2 SDK
22
22
  type GenericSessionMessage = {
23
- info: { role: string; id?: string }
23
+ info: { role: string; id?: string; parentID?: string }
24
24
  parts: Part[]
25
25
  }
26
26
 
27
27
  const logger = createLogger(LogPrefix.FORMATTING)
28
28
 
29
+ export const TOOL_PREFIX = '┣ '
30
+ export const FILE_EDIT_PREFIX = '◼︎ '
31
+ export const THINKING_PREFIX = '┣ '
32
+ export const STATUS_PREFIX = '⬦ '
33
+ export const QUEUE_PREFIX = '» '
34
+ export const WORKTREE_PREFIX = STATUS_PREFIX
35
+
29
36
  /**
30
37
  * Serialize Discord embeds into plain text so the AI model can read them.
31
38
  * Each embed becomes an <embed> XML block with title, author, description,
@@ -166,11 +173,165 @@ function normalizeWhitespace(text: string): string {
166
173
  return text.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ')
167
174
  }
168
175
 
176
+ export type SessionPartKind = 'text' | 'tool'
177
+
178
+ export function sessionPartKind(part: { type: string }): SessionPartKind {
179
+ return part.type === 'text' ? 'text' : 'tool'
180
+ }
181
+
182
+ export function shouldLeadWithBlankLine({
183
+ previousKind,
184
+ nextKind,
185
+ }: {
186
+ previousKind: SessionPartKind | undefined
187
+ nextKind: SessionPartKind
188
+ }): boolean {
189
+ if (!previousKind) return false
190
+ return previousKind !== nextKind
191
+ }
192
+
193
+ export function sessionPartContent({
194
+ content,
195
+ leadWithBlankLine,
196
+ }: {
197
+ content: string
198
+ leadWithBlankLine: boolean
199
+ }): string {
200
+ if (!leadWithBlankLine) return content
201
+ return `\n${content}`
202
+ }
203
+
204
+ export function asDiscordQuote(text: string): string {
205
+ const lead = text.startsWith('\n') ? '\n' : ''
206
+ const body = lead ? text.slice(1) : text
207
+ return lead + body.split('\n').map((line) => {
208
+ if (line.startsWith('>')) return line
209
+ return `> ${line}`
210
+ }).join('\n')
211
+ }
212
+
213
+ function isNonEmptyTextPart(part: { type: string; text?: string }): boolean {
214
+ return part.type === 'text' && Boolean(part.text?.trim())
215
+ }
216
+
217
+ function isRenderableTurnPart(part: { type: string; text?: string }): boolean {
218
+ return isNonEmptyTextPart(part) || part.type === 'tool'
219
+ }
220
+
221
+ function nextToolNameAfter({
222
+ parts,
223
+ fromIndex,
224
+ }: {
225
+ parts: Array<{ type: string; tool?: string; text?: string }>
226
+ fromIndex: number
227
+ }): string | undefined {
228
+ for (const part of parts.slice(fromIndex + 1)) {
229
+ if (isNonEmptyTextPart(part)) return undefined
230
+ if (part.type === 'tool' && part.tool) return part.tool
231
+ }
232
+ }
233
+
234
+ export function shouldQuoteIntermediateTextPart({
235
+ part,
236
+ isLastInTurn,
237
+ nextToolName,
238
+ }: {
239
+ part: { type: string; text?: string }
240
+ isLastInTurn: boolean
241
+ nextToolName?: string
242
+ }): boolean {
243
+ if (!isNonEmptyTextPart(part) || isLastInTurn) return false
244
+ const text = part.text ?? ''
245
+ if (text.includes('<callout')) return false
246
+ if (text.trim().split('\n').length > 2) return false
247
+ if (
248
+ nextToolName === 'question'
249
+ || nextToolName?.endsWith('kimaki_sleep')
250
+ || nextToolName?.endsWith('kimaki_action_buttons')
251
+ ) {
252
+ return false
253
+ }
254
+ return true
255
+ }
256
+
257
+ export type AssistantTurnFlushMode = 'progress' | 'interactive' | 'final'
258
+
259
+ export type PlannedAssistantTurnPart<T extends { id: string; type: string; text?: string }> = {
260
+ part: T
261
+ quoteText: boolean
262
+ }
263
+
264
+ export function planAssistantTurnFlush<T extends {
265
+ id: string
266
+ type: string
267
+ text?: string
268
+ tool?: string
269
+ time?: { end?: number; created?: number }
270
+ }>({
271
+ parts,
272
+ mode,
273
+ throughPartId,
274
+ }: {
275
+ parts: T[]
276
+ mode: AssistantTurnFlushMode
277
+ throughPartId?: string
278
+ }): {
279
+ send: Array<{ id: string; quoteText: boolean }>
280
+ hold: Array<{ id: string; quoteText: boolean }>
281
+ sendParts: Array<PlannedAssistantTurnPart<T>>
282
+ } {
283
+ const lastText = parts.filter(isNonEmptyTextPart).at(-1)
284
+ const lastTextIndex = lastText
285
+ ? parts.findLastIndex((part) => part.id === lastText.id)
286
+ : -1
287
+ const throughIndex = throughPartId
288
+ ? parts.findIndex((part) => part.id === throughPartId)
289
+ : -1
290
+
291
+ const sendUntil = (() => {
292
+ if (mode === 'final') return parts.length
293
+ if (mode === 'interactive') {
294
+ if (throughIndex >= 0) return throughIndex + 1
295
+ return parts.length
296
+ }
297
+ if (!lastText) return parts.length
298
+ // Hold unfinished last text and anything after it so tool order stays intact.
299
+ if (!lastText.time?.end) return lastTextIndex
300
+ return parts.length
301
+ })()
302
+
303
+ const sendParts: Array<PlannedAssistantTurnPart<T>> = []
304
+ const holdParts: T[] = []
305
+ for (const [index, part] of parts.entries()) {
306
+ if (index < sendUntil) {
307
+ sendParts.push({
308
+ part,
309
+ quoteText: shouldQuoteIntermediateTextPart({
310
+ part,
311
+ isLastInTurn:
312
+ mode !== 'progress'
313
+ && parts.filter(isRenderableTurnPart).at(-1)?.id === part.id,
314
+ nextToolName: nextToolNameAfter({ parts, fromIndex: index }),
315
+ }),
316
+ })
317
+ continue
318
+ }
319
+ holdParts.push(part)
320
+ }
321
+
322
+ return {
323
+ send: sendParts.map((entry) => ({ id: entry.part.id, quoteText: entry.quoteText })),
324
+ hold: holdParts.map((part) => ({ id: part.id, quoteText: false })),
325
+ sendParts,
326
+ }
327
+ }
328
+
169
329
  // A chunk of formatted content with associated part IDs, ready to be
170
330
  // batched into as few Discord messages as possible.
171
331
  export type SessionChunk = {
172
332
  partIds: string[]
173
333
  content: string
334
+ kind: SessionPartKind
174
335
  }
175
336
 
176
337
  /**
@@ -206,7 +367,19 @@ export function collectSessionChunks({
206
367
  if (!content.trim()) {
207
368
  continue
208
369
  }
209
- allChunks.push({ partIds: [part.id], content: content.trimEnd() })
370
+ const quote = shouldQuoteIntermediateTextPart({
371
+ part,
372
+ isLastInTurn: false,
373
+ nextToolName: nextToolNameAfter({
374
+ parts: message.parts,
375
+ fromIndex: message.parts.indexOf(part),
376
+ }),
377
+ })
378
+ allChunks.push({
379
+ partIds: [part.id],
380
+ content: (quote ? asDiscordQuote(content) : content).trimEnd(),
381
+ kind: sessionPartKind(part),
382
+ })
210
383
  }
211
384
  }
212
385
 
@@ -228,19 +401,37 @@ export function batchChunksForDiscord(chunks: SessionChunk[]): SessionChunk[] {
228
401
  return []
229
402
  }
230
403
  const batched: SessionChunk[] = []
231
- let current: SessionChunk = { partIds: [...chunks[0]!.partIds], content: chunks[0]!.content }
404
+ let current: SessionChunk = {
405
+ partIds: [...chunks[0]!.partIds],
406
+ content: chunks[0]!.content,
407
+ kind: chunks[0]!.kind,
408
+ }
232
409
 
233
410
  for (let i = 1; i < chunks.length; i++) {
234
411
  const next = chunks[i]!
412
+ if (next.kind !== current.kind) {
413
+ batched.push(current)
414
+ current = {
415
+ partIds: [...next.partIds],
416
+ content: next.content,
417
+ kind: next.kind,
418
+ }
419
+ continue
420
+ }
235
421
  const merged = current.content + '\n' + next.content
236
422
  if (merged.length <= DISCORD_BATCH_MAX_LENGTH) {
237
423
  current = {
238
424
  partIds: [...current.partIds, ...next.partIds],
239
425
  content: merged,
426
+ kind: current.kind,
240
427
  }
241
428
  } else {
242
429
  batched.push(current)
243
- current = { partIds: [...next.partIds], content: next.content }
430
+ current = {
431
+ partIds: [...next.partIds],
432
+ content: next.content,
433
+ kind: next.kind,
434
+ }
244
435
  }
245
436
  }
246
437
  batched.push(current)
@@ -419,31 +610,23 @@ export async function getFileAttachments(
419
610
  return results.filter((r) => r !== null) as DiscordFileAttachment[]
420
611
  }
421
612
 
422
- const MAX_BASH_COMMAND_INLINE_LENGTH = 100
613
+ const MAX_BASH_COMMAND_INLINE_LENGTH = 50
423
614
 
424
- /**
425
- * Format the inline title for a bash tool part. Handles three cases:
426
- * 1. Short single-line command → show full command
427
- * 2. Long/multiline command with description → show description
428
- * 3. Long/multiline command without description → truncate first line of command
429
- *
430
- * The description field was removed from the opencode v2 bash tool schema but
431
- * kimaki's system prompt instructs models to always send it as an extra field.
432
- * Case 3 is the fallback when a model omits it.
433
- */
434
615
  export function formatBashToolTitle({
435
616
  command,
436
617
  description,
618
+ summary,
437
619
  stateTitle,
438
620
  }: {
439
621
  command: string
440
622
  description?: string
623
+ summary?: string
441
624
  stateTitle?: string
442
625
  }): string {
443
- if (!command && !description && !stateTitle) return ''
626
+ const label = description || summary
627
+ if (!command && !label && !stateTitle) return ''
444
628
 
445
629
  const isSingleLine = !command.includes('\n')
446
- // Find first non-empty line to handle commands with leading blank lines
447
630
  const firstMeaningfulLine =
448
631
  command
449
632
  .split('\n')
@@ -453,8 +636,8 @@ export function formatBashToolTitle({
453
636
  if (command && isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH) {
454
637
  return ` _${escapeInlineMarkdown(command)}_`
455
638
  }
456
- if (description) {
457
- return ` _${escapeInlineMarkdown(description)}_`
639
+ if (label) {
640
+ return ` _${escapeInlineMarkdown(label)}_`
458
641
  }
459
642
  if (firstMeaningfulLine.length > 0) {
460
643
  const needsTruncation = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH
@@ -613,14 +796,10 @@ export function formatTodoList(part: Part): string {
613
796
  })
614
797
  const activeTodo = todos[activeIndex]
615
798
  if (activeIndex === -1 || !activeTodo) return ''
616
- // digit-with-period ⒈-⒛ for 1-20, fallback to regular number for 21+
617
- const digitWithPeriod = '⒈⒉⒊⒋⒌⒍⒎⒏⒐⒑⒒⒓⒔⒕⒖⒗⒘⒙⒚⒛'
618
799
  const todoNumber = activeIndex + 1
619
- const num =
620
- todoNumber <= 20 ? digitWithPeriod[todoNumber - 1] : `${todoNumber}.`
621
800
  const content =
622
801
  activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1)
623
- return `${num} **${escapeInlineMarkdown(content)}**`
802
+ return `${todoNumber}. **${escapeInlineMarkdown(content)}**`
624
803
  }
625
804
 
626
805
  export function formatTaskToolTitle(part: Extract<Part, { type: 'tool' }>): string {
@@ -638,7 +817,7 @@ export function formatTaskToolTitle(part: Extract<Part, { type: 'tool' }>): stri
638
817
 
639
818
  const subagentType = part.state.input?.subagent_type
640
819
  const agent = typeof subagentType === 'string' ? subagentType : 'task'
641
- return `┣ ${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`
820
+ return `${TOOL_PREFIX}${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`
642
821
  }
643
822
 
644
823
  export function formatPart(part: Part, prefix?: string): string {
@@ -647,25 +826,15 @@ export function formatPart(part: Part, prefix?: string): string {
647
826
  if (part.type === 'text') {
648
827
  const text = part.text?.trim()
649
828
  if (!text) return ''
650
- // For subtask text, always use bullet with prefix
651
829
  if (prefix) {
652
- return `⬥ ${pfx}${text}`
653
- }
654
- const firstChar = text[0] || ''
655
- const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|']
656
- const startsWithMarkdown =
657
- markdownStarters.includes(firstChar) ||
658
- /^\d+\./.test(text) ||
659
- /^<callout[\s>]/i.test(text)
660
- if (startsWithMarkdown) {
661
- return `\n${text}`
830
+ return `${pfx}${text}`
662
831
  }
663
- return `⬥ ${text}`
832
+ return text
664
833
  }
665
834
 
666
835
  if (part.type === 'reasoning') {
667
836
  if (!part.text?.trim()) return ''
668
- return `┣ ${pfx}thinking`
837
+ return `${THINKING_PREFIX}${pfx}thinking`
669
838
  }
670
839
 
671
840
  if (part.type === 'file') {
@@ -677,23 +846,24 @@ export function formatPart(part: Part, prefix?: string): string {
677
846
  if (
678
847
  part.type === 'step-start' ||
679
848
  part.type === 'step-finish' ||
680
- part.type === 'patch'
849
+ part.type === 'patch' ||
850
+ part.type === 'compaction'
681
851
  ) {
682
852
  return ''
683
853
  }
684
854
 
685
855
  if (part.type === 'agent') {
686
- return `┣ ${pfx}agent ${part.id}`
856
+ return `${TOOL_PREFIX}${pfx}agent ${part.id}`
687
857
  }
688
858
 
689
859
  if (part.type === 'snapshot') {
690
- return `┣ ${pfx}snapshot ${part.snapshot}`
860
+ return `${TOOL_PREFIX}${pfx}snapshot ${part.snapshot}`
691
861
  }
692
862
 
693
863
  if (part.type === 'tool') {
694
864
  if (part.tool === 'todowrite') {
695
865
  const formatted = formatTodoList(part)
696
- return prefix && formatted ? `┣ ${pfx}${formatted}` : formatted
866
+ return prefix && formatted ? `${TOOL_PREFIX}${pfx}${formatted}` : formatted
697
867
  }
698
868
 
699
869
  // Question tool is handled via Discord dropdowns, not text
@@ -722,11 +892,13 @@ export function formatPart(part: Part, prefix?: string): string {
722
892
  }
723
893
  const command = (part.state.input?.command as string) || ''
724
894
  const description = (part.state.input?.description as string) || ''
895
+ const summary = (part.state.input?.summary as string) || ''
725
896
  const toolTitle = formatBashToolTitle({
726
897
  command,
727
898
  description,
899
+ summary,
728
900
  })
729
- return `┣ ${pfx}bash${toolTitle}`
901
+ return `${TOOL_PREFIX}${pfx}bash${toolTitle}`
730
902
  }
731
903
 
732
904
  const summaryText = getToolSummaryText(part)
@@ -738,9 +910,11 @@ export function formatPart(part: Part, prefix?: string): string {
738
910
  } else if (part.tool === 'bash') {
739
911
  const command = (part.state.input?.command as string) || ''
740
912
  const description = (part.state.input?.description as string) || ''
913
+ const summary = (part.state.input?.summary as string) || ''
741
914
  const formatted = formatBashToolTitle({
742
915
  command,
743
916
  description,
917
+ summary,
744
918
  stateTitle,
745
919
  })
746
920
  toolTitle = formatted.startsWith(' ') ? formatted.slice(1) : formatted
@@ -757,14 +931,17 @@ export function formatPart(part: Part, prefix?: string): string {
757
931
  part.tool === 'write' ||
758
932
  part.tool === 'apply_patch'
759
933
  ) {
760
- return '◼︎'
934
+ return FILE_EDIT_PREFIX
761
935
  }
762
- return '┣'
936
+ return TOOL_PREFIX
763
937
  })()
764
938
  const toolParts = [part.tool, toolTitle, summaryText]
765
939
  .filter(Boolean)
766
940
  .join(' ')
767
- return `${icon} ${pfx}${toolParts}`
941
+ if (icon === '⨯') {
942
+ return `${icon} ${pfx}${toolParts}`
943
+ }
944
+ return `${icon}${pfx}${toolParts}`
768
945
  }
769
946
 
770
947
  logger.warn('Unknown part type:', part)
@@ -1,6 +1,7 @@
1
1
  // Message pre-processing pipeline for incoming Discord messages.
2
2
  // Extracts prompt text, voice transcription, file/text attachments, and
3
3
  // session context from a Discord Message before handing off to the runtime.
4
+ // Voice side/fresh-chat requests dispatch to separate threads instead.
4
5
  //
5
6
  // This module exists so discord-bot.ts stays a thin event router and the
6
7
  // expensive async work (voice transcription, context fetch, attachment
@@ -11,6 +12,7 @@ import type { Message, ThreadChannel } from 'discord.js'
11
12
  import { DiscordOperationError, OpenCodeSdkError } from './errors.js'
12
13
  import type { DiscordFileAttachment } from './message-formatting.js'
13
14
  import type { PreprocessResult } from './session-handler/thread-session-runtime.js'
15
+ import { getOrCreateRuntime } from './session-handler/thread-session-runtime.js'
14
16
  import type { AgentInfo, RepliedMessageContext } from './system-message.js'
15
17
  import {
16
18
  resolveMentions,
@@ -21,7 +23,11 @@ import { processVoiceAttachment } from './voice-handler.js'
21
23
  import { isVoiceAttachment } from './voice-attachment.js'
22
24
  import { initializeOpencodeForDirectory } from './opencode.js'
23
25
  import { getCompactSessionContext, getLastSessionId } from './markdown.js'
24
- import { getThreadSession } from './database.js'
26
+ import { getThreadSession, getThreadWorktreeOrWorkspace } from './database.js'
27
+ import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage } from './discord-utils.js'
28
+ import { forkSessionToBtwThread } from './commands/btw.js'
29
+ import { createNewSessionThread } from './commands/session.js'
30
+ import type { TranscriptionResult } from './voice.js'
25
31
  import * as errore from 'errore'
26
32
  import { createLogger, LogPrefix } from './logger.js'
27
33
  import { notifyError } from './sentry.js'
@@ -32,6 +38,96 @@ const voiceLogger = createLogger(LogPrefix.VOICE)
32
38
  export const VOICE_MESSAGE_TRANSCRIPTION_PREFIX =
33
39
  'Voice message transcription from Discord user:\n'
34
40
 
41
+ // Dispatch before returning a prompt so the source runtime never receives the side request.
42
+ async function routeVoiceSession({
43
+ voiceResult,
44
+ message,
45
+ thread,
46
+ appId,
47
+ }: {
48
+ voiceResult: TranscriptionResult | null
49
+ message: Message
50
+ thread: ThreadChannel
51
+ appId?: string
52
+ }): Promise<boolean> {
53
+ if (!voiceResult?.sessionAction) return false
54
+ const resolved = await resolveWorkingDirectory({ channel: thread })
55
+ if (!resolved) {
56
+ await sendThreadMessage(thread, 'Could not determine project directory. Use /add-project to configure it, then retry.')
57
+ return true
58
+ }
59
+ const images = await getFileAttachments(message)
60
+ const textAttachments = await getTextAttachments(message)
61
+ const caption = resolveMentions(message).trim()
62
+ const prompt = [caption, voiceResult.transcription, textAttachments]
63
+ .filter((part) => part?.trim())
64
+ .join('\n\n')
65
+ const modelPrompt = [caption, `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`, textAttachments]
66
+ .filter((part) => part?.trim())
67
+ .join('\n\n')
68
+ if (voiceResult.sessionAction === 'btw') {
69
+ const result = await forkSessionToBtwThread({
70
+ sourceThread: thread,
71
+ projectDirectory: resolved.projectDirectory,
72
+ sdkDirectory: resolved.workingDirectory,
73
+ prompt,
74
+ modelPrompt,
75
+ images,
76
+ agent: voiceResult.agent,
77
+ userId: message.author.id,
78
+ username: message.member?.displayName || message.author.displayName,
79
+ appId,
80
+ })
81
+ .catch((cause) => new DiscordOperationError({ operation: 'forkVoiceSession', cause }))
82
+ if (result instanceof Error) {
83
+ logger.error('Voice side chat failed:', result)
84
+ await sendThreadMessage(thread, `${result.message}. Start a session first, then retry with /btw.`)
85
+ return true
86
+ }
87
+ await sendThreadMessage(thread, `Session forked! Continue in ${result.thread.toString()}`)
88
+ return true
89
+ }
90
+ const textChannel = await resolveTextChannel(thread)
91
+ if (!textChannel) {
92
+ await sendThreadMessage(thread, 'Could not resolve parent text channel. Retry /new-session in the project channel.')
93
+ return true
94
+ }
95
+ const result = await createNewSessionThread({
96
+ textChannel,
97
+ projectDirectory: resolved.projectDirectory,
98
+ prompt,
99
+ userId: message.author.id,
100
+ sourceWorkspace: await getThreadWorktreeOrWorkspace(thread.id),
101
+ }).catch((cause) => new DiscordOperationError({ operation: 'createVoiceSessionThread', cause }))
102
+ if (result instanceof Error) {
103
+ logger.error('Voice new session failed:', result)
104
+ await sendThreadMessage(thread, `${result.message}. Retry with /new-session.`)
105
+ return true
106
+ }
107
+ await sendThreadMessage(thread, `Created new session in ${result.toString()}`)
108
+ const runtime = getOrCreateRuntime({
109
+ threadId: result.id,
110
+ thread: result,
111
+ projectDirectory: resolved.projectDirectory,
112
+ sdkDirectory: resolved.workingDirectory,
113
+ channelId: textChannel.id,
114
+ appId,
115
+ })
116
+ await runtime.enqueueIncoming({
117
+ prompt: modelPrompt,
118
+ images,
119
+ agent: voiceResult.agent,
120
+ userId: message.author.id,
121
+ username: message.member?.displayName || message.author.displayName,
122
+ appId,
123
+ mode: 'opencode',
124
+ }).catch(async (error) => {
125
+ logger.error('Voice session dispatch failed:', error)
126
+ await sendThreadMessage(result, 'Could not send the request to OpenCode. Send your request again in this thread.')
127
+ })
128
+ return true
129
+ }
130
+
35
131
  /** Fetch available agents from OpenCode for voice transcription agent selection. */
36
132
  async function fetchAvailableAgents(
37
133
  getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>,
@@ -245,7 +341,15 @@ export async function preprocessExistingThreadMessage({
245
341
  currentSessionContext,
246
342
  lastSessionContext,
247
343
  agents,
344
+ canForkSession: true,
248
345
  })
346
+ if (voiceResult instanceof Error) {
347
+ voiceLogger.warn('Voice request not dispatched:', voiceResult)
348
+ return { prompt: '', mode: 'opencode', skip: true }
349
+ }
350
+ if (await routeVoiceSession({ voiceResult, message, thread, appId })) {
351
+ return { prompt: '', mode: 'opencode', skip: true }
352
+ }
249
353
  if (voiceResult) {
250
354
  messageContent = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`
251
355
  }
@@ -327,6 +431,13 @@ export async function preprocessNewSessionMessage({
327
431
  appId,
328
432
  agents,
329
433
  })
434
+ if (voiceResult instanceof Error) {
435
+ voiceLogger.warn('Voice request not dispatched:', voiceResult)
436
+ return { prompt: '', mode: 'opencode', skip: true }
437
+ }
438
+ if (await routeVoiceSession({ voiceResult, message, thread, appId })) {
439
+ return { prompt: '', mode: 'opencode', skip: true }
440
+ }
330
441
  if (voiceResult) {
331
442
  prompt = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`
332
443
  }
@@ -424,6 +535,10 @@ export async function preprocessNewThreadMessage({
424
535
  appId,
425
536
  agents,
426
537
  })
538
+ if (voiceResult instanceof Error) {
539
+ voiceLogger.warn('Voice request not dispatched:', voiceResult)
540
+ return { prompt: '', mode: 'opencode', skip: true }
541
+ }
427
542
  if (voiceResult) {
428
543
  messageContent = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`
429
544
  }
@@ -7,11 +7,70 @@ import path from 'node:path'
7
7
  import { afterEach, beforeEach, describe, expect, test } from 'vitest'
8
8
  import {
9
9
  ensureKimakiCommandShim,
10
+ getIncompatibleOpencodeVersionError,
10
11
  getSpawnCommandAndArgs,
12
+ INCOMPATIBLE_OPENCODE_MAJOR_VERSION,
13
+ isIncompatibleOpencodeMajor,
14
+ parseOpencodeVersion,
11
15
  sanitizeShimExecArgv,
12
16
  selectResolvedCommand,
13
17
  splitCommandLookupOutput,
14
18
  } from './opencode-command.js'
19
+ import { OpencodeIncompatibleVersionError } from './errors.js'
20
+
21
+ describe('parseOpencodeVersion', () => {
22
+ test('extracts major.minor.patch from opencode --version output', () => {
23
+ expect(parseOpencodeVersion('1.2.3')).toEqual({
24
+ major: 1,
25
+ minor: 2,
26
+ patch: 3,
27
+ raw: '1.2.3',
28
+ })
29
+ expect(parseOpencodeVersion('opencode 2.0.0\n')).toEqual({
30
+ major: 2,
31
+ minor: 0,
32
+ patch: 0,
33
+ raw: '2.0.0',
34
+ })
35
+ expect(parseOpencodeVersion('v2.1.0-beta.1')).toEqual({
36
+ major: 2,
37
+ minor: 1,
38
+ patch: 0,
39
+ raw: '2.1.0',
40
+ })
41
+ })
42
+
43
+ test('returns null when output has no three-part version', () => {
44
+ expect(parseOpencodeVersion('')).toBeNull()
45
+ expect(parseOpencodeVersion('opencode')).toBeNull()
46
+ expect(parseOpencodeVersion('2.0')).toBeNull()
47
+ })
48
+ })
49
+
50
+ describe('isIncompatibleOpencodeMajor', () => {
51
+ test('rejects the incompatible major and allows every other major', () => {
52
+ expect(INCOMPATIBLE_OPENCODE_MAJOR_VERSION).toBe(2)
53
+ expect(isIncompatibleOpencodeMajor({ major: 2 })).toBe(true)
54
+ expect(isIncompatibleOpencodeMajor({ major: 1 })).toBe(false)
55
+ expect(isIncompatibleOpencodeMajor({ major: 0 })).toBe(false)
56
+ expect(isIncompatibleOpencodeMajor({ major: 3 })).toBe(false)
57
+ })
58
+ })
59
+
60
+ describe('getIncompatibleOpencodeVersionError', () => {
61
+ test('returns a tagged error for OpenCode 2.x', () => {
62
+ const error = getIncompatibleOpencodeVersionError('2.0.0')
63
+ expect(error).toBeInstanceOf(OpencodeIncompatibleVersionError)
64
+ expect(error?.message).toMatchInlineSnapshot(
65
+ `"Kimaki is not compatible with OpenCode version 2.0.0. Install an OpenCode 1.x release."`,
66
+ )
67
+ })
68
+
69
+ test('allows 1.x and unparseable output', () => {
70
+ expect(getIncompatibleOpencodeVersionError('1.4.0')).toBeNull()
71
+ expect(getIncompatibleOpencodeVersionError('opencode')).toBeNull()
72
+ })
73
+ })
15
74
 
16
75
  describe('splitCommandLookupOutput', () => {
17
76
  test('splits windows command lookup output into trimmed lines', () => {