kimaki 0.26.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 (302) hide show
  1. package/dist/agent-model.e2e.test.js +250 -164
  2. package/dist/anthropic-account-identity.js +9 -1
  3. package/dist/anthropic-auth-plugin.js +10 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bash-tool-schema-plugin.js +32 -0
  7. package/dist/bash-tool-schema-plugin.test.js +49 -0
  8. package/dist/bin.js +73 -19
  9. package/dist/cli-commands/maintenance.js +46 -7
  10. package/dist/cli-commands/project.js +11 -27
  11. package/dist/cli-commands/send.js +30 -1
  12. package/dist/cli-commands/session.js +322 -90
  13. package/dist/cli-commands/task.js +34 -15
  14. package/dist/cli-parsing.test.js +83 -4
  15. package/dist/cli-runner.js +44 -8
  16. package/dist/cli-runner.test.js +27 -0
  17. package/dist/cli-send-thread.e2e.test.js +9 -9
  18. package/dist/cli.js +32 -0
  19. package/dist/commands/abort.js +5 -1
  20. package/dist/commands/action-buttons.js +2 -1
  21. package/dist/commands/add-project.js +4 -7
  22. package/dist/commands/agent.js +164 -32
  23. package/dist/commands/ask-question.js +23 -33
  24. package/dist/commands/ask-question.test.js +43 -1
  25. package/dist/commands/btw.js +28 -6
  26. package/dist/commands/file-upload.js +2 -1
  27. package/dist/commands/fork-subagent.js +2 -4
  28. package/dist/commands/fork.js +4 -4
  29. package/dist/commands/last-sessions.js +17 -10
  30. package/dist/commands/login.js +6 -0
  31. package/dist/commands/merge-worktree.js +47 -14
  32. package/dist/commands/model-variant.js +8 -1
  33. package/dist/commands/model.js +16 -9
  34. package/dist/commands/multioauth.js +1 -0
  35. package/dist/commands/new-worktree.js +170 -102
  36. package/dist/commands/queue.js +3 -2
  37. package/dist/commands/resume.js +4 -4
  38. package/dist/commands/session.js +78 -63
  39. package/dist/commands/user-command.js +6 -8
  40. package/dist/commands/worktrees.js +5 -13
  41. package/dist/config.js +16 -1
  42. package/dist/cpu-profiler.js +211 -0
  43. package/dist/cpu-profiler.test.js +160 -0
  44. package/dist/database.js +152 -3
  45. package/dist/db.js +33 -0
  46. package/dist/db.test.js +267 -1
  47. package/dist/default-channel-provisioning.test.js +148 -0
  48. package/dist/discord-bot.js +517 -488
  49. package/dist/discord-command-registration.js +25 -10
  50. package/dist/discord-command-registration.test.js +17 -0
  51. package/dist/discord-utils.js +97 -5
  52. package/dist/discord-utils.test.js +151 -1
  53. package/dist/errors.js +10 -0
  54. package/dist/exec-async.js +17 -5
  55. package/dist/external-directory-permissions.e2e.test.js +11 -7
  56. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  57. package/dist/external-opencode-sync.js +94 -17
  58. package/dist/external-opencode-sync.test.js +206 -0
  59. package/dist/extract-sdk-error-message.test.js +13 -0
  60. package/dist/file-edit-log.js +197 -0
  61. package/dist/file-edit-log.test.js +258 -0
  62. package/dist/format-tables.js +52 -0
  63. package/dist/format-tables.test.js +93 -0
  64. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  65. package/dist/gateway-proxy.e2e.test.js +18 -18
  66. package/dist/git-worktree-core.js +182 -17
  67. package/dist/interaction-handler.js +416 -398
  68. package/dist/ipc-tools-plugin.js +68 -3
  69. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  70. package/dist/kimaki-opencode-plugin.js +9 -3
  71. package/dist/kimaki-workspace-adaptor.js +33 -10
  72. package/dist/logger.js +11 -1
  73. package/dist/markdown.js +3 -1
  74. package/dist/markdown.test.js +11 -3
  75. package/dist/message-formatting.js +181 -51
  76. package/dist/message-formatting.test.js +529 -22
  77. package/dist/message-preprocessing.js +105 -1
  78. package/dist/oauth-rotation-shared.js +10 -0
  79. package/dist/openai-auth-plugin.js +1 -0
  80. package/dist/openai-auth-state.js +1 -0
  81. package/dist/opencode-command.js +28 -0
  82. package/dist/opencode-command.test.js +109 -1
  83. package/dist/opencode-interrupt-plugin.js +4 -0
  84. package/dist/opencode-interrupt-plugin.test.js +24 -0
  85. package/dist/opencode.js +82 -15
  86. package/dist/project-add.test.js +62 -0
  87. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  88. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  89. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  90. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  91. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  92. package/dist/queue-advanced-e2e-setup.js +78 -5
  93. package/dist/queue-advanced-footer.e2e.test.js +60 -269
  94. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  95. package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
  96. package/dist/queue-advanced-question.e2e.test.js +32 -27
  97. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  98. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  99. package/dist/queue-delete-message.e2e.test.js +3 -3
  100. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  101. package/dist/queue-interrupt-drain.e2e.test.js +122 -12
  102. package/dist/queue-question-select-drain.e2e.test.js +34 -132
  103. package/dist/runtime-lifecycle.e2e.test.js +21 -51
  104. package/dist/schema.js +35 -1
  105. package/dist/session-handler/event-stream-state.js +391 -50
  106. package/dist/session-handler/event-stream-state.test.js +736 -8
  107. package/dist/session-handler/model-utils.js +172 -5
  108. package/dist/session-handler/model-utils.test.js +445 -0
  109. package/dist/session-handler/opencode-session-event-log.js +30 -1
  110. package/dist/session-handler/thread-ingress.test.js +41 -0
  111. package/dist/session-handler/thread-runtime-state.js +2 -0
  112. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  113. package/dist/session-handler/thread-session-runtime.js +574 -299
  114. package/dist/session-search.js +123 -4
  115. package/dist/session-search.test.js +248 -1
  116. package/dist/session-sleep.test.js +224 -0
  117. package/dist/session-title-rename.test.js +37 -39
  118. package/dist/sleep.e2e.test.js +494 -0
  119. package/dist/startup-time.e2e.test.js +2 -1
  120. package/dist/store.js +3 -0
  121. package/dist/system-message.js +102 -16
  122. package/dist/system-message.test.js +192 -15
  123. package/dist/task-runner.js +96 -5
  124. package/dist/task-schedule.js +87 -1
  125. package/dist/task-schedule.test.js +149 -1
  126. package/dist/test-utils.js +17 -9
  127. package/dist/thinking-utils.js +10 -0
  128. package/dist/thinking-utils.test.js +35 -0
  129. package/dist/thread-message-queue.e2e.test.js +65 -213
  130. package/dist/undo-redo.e2e.test.js +17 -9
  131. package/dist/voice-attachment.js +23 -1
  132. package/dist/voice-handler.js +21 -4
  133. package/dist/voice-message.e2e.test.js +144 -30
  134. package/dist/voice-routing-input.e2e.test.js +195 -0
  135. package/dist/voice-routing-question.e2e.test.js +125 -0
  136. package/dist/voice.js +28 -8
  137. package/dist/voice.test.js +96 -30
  138. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  139. package/dist/worktree-lifecycle.e2e.test.js +55 -36
  140. package/dist/worktrees.js +226 -443
  141. package/dist/worktrees.test.js +435 -58
  142. package/dist/xai-auth-plugin.js +1 -0
  143. package/dist/xai-auth-state.js +1 -0
  144. package/package.json +8 -6
  145. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  146. package/skills/critique/SKILL.md +4 -4
  147. package/skills/errore/SKILL.md +60 -218
  148. package/skills/goke/SKILL.md +150 -10
  149. package/skills/holocron/SKILL.md +154 -17
  150. package/skills/npm-package/SKILL.md +55 -35
  151. package/skills/profano/SKILL.md +17 -2
  152. package/skills/spiceflow/SKILL.md +39 -3
  153. package/skills/strada/SKILL.md +39 -0
  154. package/skills/zele/SKILL.md +19 -0
  155. package/src/agent-model.e2e.test.ts +287 -186
  156. package/src/anthropic-account-identity.ts +20 -1
  157. package/src/anthropic-auth-plugin.test.ts +19 -0
  158. package/src/anthropic-auth-plugin.ts +12 -16
  159. package/src/anthropic-auth-state.ts +1 -0
  160. package/src/bash-tool-schema-plugin.test.ts +55 -0
  161. package/src/bash-tool-schema-plugin.ts +46 -0
  162. package/src/bin.ts +78 -26
  163. package/src/cli-commands/maintenance.ts +59 -13
  164. package/src/cli-commands/project.ts +14 -35
  165. package/src/cli-commands/send.ts +31 -1
  166. package/src/cli-commands/session.ts +374 -114
  167. package/src/cli-commands/task.ts +34 -15
  168. package/src/cli-parsing.test.ts +103 -4
  169. package/src/cli-runner.test.ts +49 -0
  170. package/src/cli-runner.ts +44 -7
  171. package/src/cli-send-thread.e2e.test.ts +9 -11
  172. package/src/cli.ts +53 -0
  173. package/src/commands/abort.ts +6 -1
  174. package/src/commands/action-buttons.ts +2 -1
  175. package/src/commands/add-project.ts +4 -8
  176. package/src/commands/agent.ts +205 -29
  177. package/src/commands/ask-question.test.ts +48 -1
  178. package/src/commands/ask-question.ts +23 -33
  179. package/src/commands/btw.ts +40 -5
  180. package/src/commands/file-upload.ts +2 -1
  181. package/src/commands/fork-subagent.ts +2 -3
  182. package/src/commands/fork.ts +4 -3
  183. package/src/commands/last-sessions.ts +10 -4
  184. package/src/commands/login.ts +6 -0
  185. package/src/commands/merge-worktree.ts +54 -16
  186. package/src/commands/model-variant.ts +9 -1
  187. package/src/commands/model.ts +20 -9
  188. package/src/commands/multioauth.ts +1 -0
  189. package/src/commands/new-worktree.ts +138 -55
  190. package/src/commands/queue.ts +3 -2
  191. package/src/commands/resume.ts +4 -3
  192. package/src/commands/session.ts +94 -70
  193. package/src/commands/user-command.ts +6 -6
  194. package/src/commands/worktrees.ts +5 -12
  195. package/src/config.ts +18 -1
  196. package/src/cpu-profiler.test.ts +183 -0
  197. package/src/cpu-profiler.ts +293 -0
  198. package/src/database.ts +235 -3
  199. package/src/db.test.ts +293 -0
  200. package/src/db.ts +38 -0
  201. package/src/default-channel-provisioning.test.ts +155 -0
  202. package/src/discord-bot.ts +78 -36
  203. package/src/discord-command-registration.test.ts +20 -0
  204. package/src/discord-command-registration.ts +42 -12
  205. package/src/discord-utils.test.ts +176 -0
  206. package/src/discord-utils.ts +152 -7
  207. package/src/errors.ts +12 -0
  208. package/src/exec-async.ts +20 -11
  209. package/src/external-directory-permissions.e2e.test.ts +11 -7
  210. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  211. package/src/external-opencode-sync.test.ts +248 -0
  212. package/src/external-opencode-sync.ts +122 -19
  213. package/src/extract-sdk-error-message.test.ts +18 -0
  214. package/src/file-edit-log.test.ts +287 -0
  215. package/src/file-edit-log.ts +283 -0
  216. package/src/format-tables.test.ts +101 -0
  217. package/src/format-tables.ts +75 -0
  218. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  219. package/src/gateway-proxy.e2e.test.ts +18 -17
  220. package/src/git-worktree-core.ts +247 -20
  221. package/src/interaction-handler.ts +36 -13
  222. package/src/ipc-tools-plugin.ts +82 -5
  223. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  224. package/src/kimaki-opencode-plugin.ts +9 -3
  225. package/src/kimaki-workspace-adaptor.ts +40 -11
  226. package/src/logger.ts +14 -4
  227. package/src/markdown.test.ts +13 -3
  228. package/src/markdown.ts +4 -1
  229. package/src/message-formatting.test.ts +672 -25
  230. package/src/message-formatting.ts +242 -53
  231. package/src/message-preprocessing.ts +116 -1
  232. package/src/oauth-rotation-shared.ts +10 -0
  233. package/src/openai-auth-plugin.ts +1 -0
  234. package/src/openai-auth-state.ts +1 -0
  235. package/src/opencode-command.test.ts +140 -0
  236. package/src/opencode-command.ts +38 -0
  237. package/src/opencode-interrupt-plugin.test.ts +32 -0
  238. package/src/opencode-interrupt-plugin.ts +3 -0
  239. package/src/opencode.ts +115 -15
  240. package/src/project-add.test.ts +80 -0
  241. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  242. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  243. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  244. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  245. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  246. package/src/queue-advanced-e2e-setup.ts +83 -4
  247. package/src/queue-advanced-footer.e2e.test.ts +67 -315
  248. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  249. package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
  250. package/src/queue-advanced-question.e2e.test.ts +36 -30
  251. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  252. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  253. package/src/queue-delete-message.e2e.test.ts +3 -3
  254. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  255. package/src/queue-interrupt-drain.e2e.test.ts +133 -12
  256. package/src/queue-question-select-drain.e2e.test.ts +36 -161
  257. package/src/runtime-lifecycle.e2e.test.ts +21 -54
  258. package/src/schema.sql +15 -2
  259. package/src/schema.ts +37 -1
  260. package/src/session-handler/event-stream-state.test.ts +790 -7
  261. package/src/session-handler/event-stream-state.ts +568 -53
  262. package/src/session-handler/model-utils.test.ts +537 -0
  263. package/src/session-handler/model-utils.ts +281 -5
  264. package/src/session-handler/opencode-session-event-log.ts +31 -1
  265. package/src/session-handler/thread-ingress.test.ts +54 -0
  266. package/src/session-handler/thread-runtime-state.ts +3 -0
  267. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  268. package/src/session-handler/thread-session-runtime.ts +712 -358
  269. package/src/session-search.test.ts +281 -0
  270. package/src/session-search.ts +241 -7
  271. package/src/session-sleep.test.ts +294 -0
  272. package/src/session-title-rename.test.ts +54 -48
  273. package/src/sleep.e2e.test.ts +565 -0
  274. package/src/startup-time.e2e.test.ts +5 -1
  275. package/src/store.ts +22 -0
  276. package/src/system-message.test.ts +226 -15
  277. package/src/system-message.ts +130 -16
  278. package/src/task-runner.ts +138 -3
  279. package/src/task-schedule.test.ts +170 -0
  280. package/src/task-schedule.ts +141 -1
  281. package/src/test-utils.ts +21 -10
  282. package/src/thinking-utils.test.ts +46 -0
  283. package/src/thinking-utils.ts +21 -0
  284. package/src/thread-message-queue.e2e.test.ts +65 -245
  285. package/src/undo-redo.e2e.test.ts +17 -9
  286. package/src/voice-attachment.ts +29 -1
  287. package/src/voice-handler.ts +23 -4
  288. package/src/voice-message.e2e.test.ts +149 -29
  289. package/src/voice-routing-input.e2e.test.ts +203 -0
  290. package/src/voice-routing-question.e2e.test.ts +131 -0
  291. package/src/voice.test.ts +107 -29
  292. package/src/voice.ts +34 -6
  293. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  294. package/src/worktree-lifecycle.e2e.test.ts +55 -35
  295. package/src/worktrees.test.ts +505 -64
  296. package/src/worktrees.ts +257 -603
  297. package/src/xai-auth-plugin.ts +1 -0
  298. package/src/xai-auth-state.ts +1 -0
  299. package/dist/message-finish-field.e2e.test.js +0 -169
  300. package/dist/subagent-rate-limit-plugin.js +0 -184
  301. package/src/message-finish-field.e2e.test.ts +0 -196
  302. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -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
@@ -566,6 +749,22 @@ export function getToolSummaryText(part: Part): string {
566
749
  return prompt ? `*${escapeInlineMarkdown(prompt.slice(0, 60))}*` : ''
567
750
  }
568
751
 
752
+ // LESSON: tool parts are sent to Discord at `running` status (see
753
+ // shouldSendPart in thread-session-runtime), so `state.title` from the tool
754
+ // result is never rendered here. Everything shown must come from state.input.
755
+ // `until` is already absolute; `duration` is relative, so say "for 2h" —
756
+ // never "until 2h".
757
+ if (part.tool.endsWith('kimaki_sleep')) {
758
+ const until = (part.state.input?.until as string) || ''
759
+ const duration = (part.state.input?.duration as string) || ''
760
+ const reason = (part.state.input?.reason as string) || ''
761
+ const when = until ? `until ${until}` : duration ? `for ${duration}` : ''
762
+ const reasonText = reason ? `_${escapeInlineMarkdown(reason)}_` : ''
763
+ return [when && escapeInlineMarkdown(when), reasonText]
764
+ .filter(Boolean)
765
+ .join(' ')
766
+ }
767
+
569
768
  if (!part.state.input) return ''
570
769
 
571
770
  const inputFields = Object.entries(part.state.input)
@@ -597,26 +796,18 @@ export function formatTodoList(part: Part): string {
597
796
  })
598
797
  const activeTodo = todos[activeIndex]
599
798
  if (activeIndex === -1 || !activeTodo) return ''
600
- // digit-with-period ⒈-⒛ for 1-20, fallback to regular number for 21+
601
- const digitWithPeriod = '⒈⒉⒊⒋⒌⒍⒎⒏⒐⒑⒒⒓⒔⒕⒖⒗⒘⒙⒚⒛'
602
799
  const todoNumber = activeIndex + 1
603
- const num =
604
- todoNumber <= 20 ? digitWithPeriod[todoNumber - 1] : `${todoNumber}.`
605
800
  const content =
606
801
  activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1)
607
- return `${num} **${escapeInlineMarkdown(content)}**`
802
+ return `${todoNumber}. **${escapeInlineMarkdown(content)}**`
608
803
  }
609
804
 
610
805
  export function formatTaskToolTitle(part: Extract<Part, { type: 'tool' }>): string {
611
- if (part.tool !== 'task' || part.state.status === 'pending') return ''
612
-
613
- const childSessionId = part.state.metadata?.sessionId
614
- if (typeof childSessionId !== 'string' || !childSessionId) return ''
806
+ // Running only. The child session can be created later when many tasks queue.
807
+ if (part.tool !== 'task' || part.state.status !== 'running') return ''
615
808
 
616
809
  const description = part.state.input?.description
617
- const stateTitle = part.state.status === 'running' || part.state.status === 'completed'
618
- ? part.state.title
619
- : undefined
810
+ const stateTitle = part.state.title
620
811
  const title = typeof description === 'string' && description
621
812
  ? description
622
813
  : typeof stateTitle === 'string'
@@ -626,7 +817,7 @@ export function formatTaskToolTitle(part: Extract<Part, { type: 'tool' }>): stri
626
817
 
627
818
  const subagentType = part.state.input?.subagent_type
628
819
  const agent = typeof subagentType === 'string' ? subagentType : 'task'
629
- return `┣ ${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`
820
+ return `${TOOL_PREFIX}${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`
630
821
  }
631
822
 
632
823
  export function formatPart(part: Part, prefix?: string): string {
@@ -635,25 +826,15 @@ export function formatPart(part: Part, prefix?: string): string {
635
826
  if (part.type === 'text') {
636
827
  const text = part.text?.trim()
637
828
  if (!text) return ''
638
- // For subtask text, always use bullet with prefix
639
829
  if (prefix) {
640
- return `⬥ ${pfx}${text}`
830
+ return `${pfx}${text}`
641
831
  }
642
- const firstChar = text[0] || ''
643
- const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|']
644
- const startsWithMarkdown =
645
- markdownStarters.includes(firstChar) ||
646
- /^\d+\./.test(text) ||
647
- /^<callout[\s>]/i.test(text)
648
- if (startsWithMarkdown) {
649
- return `\n${text}`
650
- }
651
- return `⬥ ${text}`
832
+ return text
652
833
  }
653
834
 
654
835
  if (part.type === 'reasoning') {
655
836
  if (!part.text?.trim()) return ''
656
- return `┣ ${pfx}thinking`
837
+ return `${THINKING_PREFIX}${pfx}thinking`
657
838
  }
658
839
 
659
840
  if (part.type === 'file') {
@@ -665,23 +846,24 @@ export function formatPart(part: Part, prefix?: string): string {
665
846
  if (
666
847
  part.type === 'step-start' ||
667
848
  part.type === 'step-finish' ||
668
- part.type === 'patch'
849
+ part.type === 'patch' ||
850
+ part.type === 'compaction'
669
851
  ) {
670
852
  return ''
671
853
  }
672
854
 
673
855
  if (part.type === 'agent') {
674
- return `┣ ${pfx}agent ${part.id}`
856
+ return `${TOOL_PREFIX}${pfx}agent ${part.id}`
675
857
  }
676
858
 
677
859
  if (part.type === 'snapshot') {
678
- return `┣ ${pfx}snapshot ${part.snapshot}`
860
+ return `${TOOL_PREFIX}${pfx}snapshot ${part.snapshot}`
679
861
  }
680
862
 
681
863
  if (part.type === 'tool') {
682
864
  if (part.tool === 'todowrite') {
683
865
  const formatted = formatTodoList(part)
684
- return prefix && formatted ? `┣ ${pfx}${formatted}` : formatted
866
+ return prefix && formatted ? `${TOOL_PREFIX}${pfx}${formatted}` : formatted
685
867
  }
686
868
 
687
869
  // Question tool is handled via Discord dropdowns, not text
@@ -710,11 +892,13 @@ export function formatPart(part: Part, prefix?: string): string {
710
892
  }
711
893
  const command = (part.state.input?.command as string) || ''
712
894
  const description = (part.state.input?.description as string) || ''
895
+ const summary = (part.state.input?.summary as string) || ''
713
896
  const toolTitle = formatBashToolTitle({
714
897
  command,
715
898
  description,
899
+ summary,
716
900
  })
717
- return `┣ ${pfx}bash${toolTitle}`
901
+ return `${TOOL_PREFIX}${pfx}bash${toolTitle}`
718
902
  }
719
903
 
720
904
  const summaryText = getToolSummaryText(part)
@@ -726,9 +910,11 @@ export function formatPart(part: Part, prefix?: string): string {
726
910
  } else if (part.tool === 'bash') {
727
911
  const command = (part.state.input?.command as string) || ''
728
912
  const description = (part.state.input?.description as string) || ''
913
+ const summary = (part.state.input?.summary as string) || ''
729
914
  const formatted = formatBashToolTitle({
730
915
  command,
731
916
  description,
917
+ summary,
732
918
  stateTitle,
733
919
  })
734
920
  toolTitle = formatted.startsWith(' ') ? formatted.slice(1) : formatted
@@ -745,14 +931,17 @@ export function formatPart(part: Part, prefix?: string): string {
745
931
  part.tool === 'write' ||
746
932
  part.tool === 'apply_patch'
747
933
  ) {
748
- return '◼︎'
934
+ return FILE_EDIT_PREFIX
749
935
  }
750
- return '┣'
936
+ return TOOL_PREFIX
751
937
  })()
752
938
  const toolParts = [part.tool, toolTitle, summaryText]
753
939
  .filter(Boolean)
754
940
  .join(' ')
755
- return `${icon} ${pfx}${toolParts}`
941
+ if (icon === '⨯') {
942
+ return `${icon} ${pfx}${toolParts}`
943
+ }
944
+ return `${icon}${pfx}${toolParts}`
756
945
  }
757
946
 
758
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
  }
@@ -1,3 +1,13 @@
1
+ /**
2
+ * LEGACY. Superseded by @subrouter/opencode, registered alongside the kimaki
3
+ * plugin in opencode.ts. This whole rotation path only cycles accounts inside
4
+ * one provider; subrouter also fails over to a different provider when every
5
+ * account of one is exhausted.
6
+ *
7
+ * Kept because opencode ships no Claude Pro/Max auth of its own, so deleting
8
+ * it would strand anyone still picking `anthropic/*` directly. To move to
9
+ * subrouter, log in again through /login and pick Subrouter.
10
+ */
1
11
  /**
2
12
  * Shared utilities for multi-provider OAuth account rotation.
3
13
  * Used by both anthropic-auth-state.ts and openai-auth-state.ts to avoid
@@ -1,3 +1,4 @@
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
1
2
  /**
2
3
  * OpenAI OAuth rotation plugin for OpenCode.
3
4
  *
@@ -1,3 +1,4 @@
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
1
2
  /**
2
3
  * OpenAI OAuth account store and rotation.
3
4
  * Mirrors anthropic-auth-state.ts but for OpenAI/Codex OAuth accounts.