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
@@ -9,6 +9,7 @@ import {
9
9
  type ThreadChannel,
10
10
  type Message,
11
11
  } from 'discord.js'
12
+ import crypto from 'node:crypto'
12
13
  import fs from 'node:fs'
13
14
  import { OpenCodeSdkError } from '../errors.js'
14
15
  import type { CommandContext } from './types.js'
@@ -18,7 +19,6 @@ import {
18
19
  setWorkspaceError,
19
20
  getChannelDirectory,
20
21
  getThreadSession,
21
- getThreadWorktreeOrWorkspace,
22
22
  setThreadSession,
23
23
  } from '../database.js'
24
24
  import {
@@ -32,10 +32,17 @@ import { createLogger, LogPrefix } from '../logger.js'
32
32
  import { notifyError } from '../sentry.js'
33
33
  import {
34
34
  execAsync,
35
+ getManagedWorktreeDirectory,
35
36
  listBranchesByLastCommit,
36
37
  resolveBestBaseRef,
37
38
  validateBranchRef,
38
39
  } from '../worktrees.js'
40
+ import {
41
+ KIMAKI_WORKTREE_ADAPTER_TYPE,
42
+ removeWorktreeFromOwnRepository,
43
+ resolveGitCommit,
44
+ validateWorktreeIdentity,
45
+ } from '../git-worktree-core.js'
39
46
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
40
47
  import {
41
48
  buildSessionPermissions,
@@ -47,8 +54,6 @@ import * as errore from 'errore'
47
54
  import { copyCurrentSessionModel } from './model.js'
48
55
 
49
56
  const logger = createLogger(LogPrefix.WORKTREE)
50
- const DEFAULT_WORKTREE_BASE_REF = 'HEAD'
51
-
52
57
  async function resolveRequestedWorktreeBaseRef({
53
58
  projectDirectory,
54
59
  rawBaseBranch,
@@ -59,7 +64,7 @@ async function resolveRequestedWorktreeBaseRef({
59
64
  if (!rawBaseBranch) {
60
65
  // Default to the current local HEAD so worktrees can branch from
61
66
  // unpublished commits in the main checkout.
62
- return DEFAULT_WORKTREE_BASE_REF
67
+ return resolveGitCommit({ directory: projectDirectory, ref: 'HEAD' })
63
68
  }
64
69
 
65
70
  const validated = await validateBranchRef({
@@ -77,7 +82,7 @@ async function resolveRequestedWorktreeBaseRef({
77
82
  if (bestRef !== validated) {
78
83
  logger.log(`Base branch resolved: ${validated} → ${bestRef} (remote is ahead)`)
79
84
  }
80
- return bestRef
85
+ return resolveGitCommit({ directory: projectDirectory, ref: bestRef })
81
86
  }
82
87
 
83
88
  /** Status message shown while a worktree is being created. */
@@ -211,37 +216,95 @@ async function getProjectDirectoryFromChannel(
211
216
  /**
212
217
  * Try creating a worktree via the OpenCode workspace SDK.
213
218
  * Returns the workspace directory on success, or an Error if the workspace
214
- * feature is not available or the creation fails. Callers fall back to the
215
- * direct git path on Error.
219
+ * feature is not available or the creation fails.
216
220
  */
217
- async function tryWorkspaceCreate({
218
- threadId,
221
+ export async function tryWorkspaceCreate({
219
222
  worktreeName,
220
223
  projectDirectory,
221
- baseBranch,
224
+ baseCommit,
222
225
  }: {
223
- threadId: string
224
226
  worktreeName: string
225
227
  projectDirectory: string
226
- baseBranch?: string
228
+ baseCommit: string
227
229
  }): Promise<{ directory: string; workspaceId: string } | Error> {
228
230
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
229
231
  if (getClient instanceof Error) return getClient
230
232
 
231
233
  const client = getClient()
234
+ const workspaceId = `wrk_${crypto.randomUUID()}`
235
+ const managedDirectory = getManagedWorktreeDirectory({
236
+ directory: projectDirectory,
237
+ name: worktreeName,
238
+ })
239
+ const cleanupFailedWorkspace = async (worktreeDirectory: string) => {
240
+ const removeResponse = await client.experimental.workspace.remove({
241
+ id: workspaceId,
242
+ directory: projectDirectory,
243
+ }).catch((e) => new OpenCodeSdkError({ operation: 'workspace.remove', cause: e }))
244
+ const sdkCleanupError = (() => {
245
+ if (removeResponse instanceof Error) return removeResponse
246
+ if (removeResponse.error) {
247
+ return new Error(`Workspace cleanup failed: ${JSON.stringify(removeResponse.error)}`)
248
+ }
249
+ return undefined
250
+ })()
251
+ const gitCleanupError = fs.existsSync(worktreeDirectory)
252
+ ? await removeWorktreeFromOwnRepository({
253
+ worktreeDirectory,
254
+ branchName: worktreeName,
255
+ })
256
+ : undefined
257
+ return sdkCleanupError ?? gitCleanupError
258
+ }
259
+
232
260
  const response = await client.experimental.workspace.create({
261
+ id: workspaceId,
233
262
  directory: projectDirectory,
234
- type: 'kimaki-worktree',
263
+ type: KIMAKI_WORKTREE_ADAPTER_TYPE,
235
264
  branch: worktreeName,
236
- extra: baseBranch ? { baseBranch } : null,
265
+ extra: {
266
+ projectDirectory,
267
+ baseCommit,
268
+ },
237
269
  }).catch((e) => new OpenCodeSdkError({ operation: 'workspace.create', cause: e }))
238
- if (response instanceof Error) return response
239
- if (response.error) {
240
- return new Error(`Workspace creation failed: ${JSON.stringify(response.error)}`)
270
+ if (response instanceof Error || response.error) {
271
+ const creationError = response instanceof Error
272
+ ? response
273
+ : new Error(`Workspace creation failed: ${JSON.stringify(response.error)}`)
274
+ const cleanupError = await cleanupFailedWorkspace(managedDirectory)
275
+ if (cleanupError instanceof Error) {
276
+ return new Error(`${creationError.message}; cleanup failed: ${cleanupError.message}`, {
277
+ cause: creationError,
278
+ })
279
+ }
280
+ return creationError
241
281
  }
242
282
  const workspace = response.data
243
283
  if (!workspace?.directory || !workspace.id) {
244
- return new Error('Workspace SDK returned no directory or ID')
284
+ const creationError = new Error('Workspace SDK returned no directory or ID')
285
+ const cleanupError = await cleanupFailedWorkspace(managedDirectory)
286
+ if (cleanupError instanceof Error) {
287
+ return new Error(`${creationError.message}; cleanup failed: ${cleanupError.message}`, {
288
+ cause: creationError,
289
+ })
290
+ }
291
+ return creationError
292
+ }
293
+
294
+ const identityResult = await validateWorktreeIdentity({
295
+ projectDirectory,
296
+ worktreeDirectory: workspace.directory,
297
+ baseCommit,
298
+ })
299
+ if (identityResult instanceof Error) {
300
+ const cleanupError = await cleanupFailedWorkspace(workspace.directory)
301
+ if (cleanupError instanceof Error) {
302
+ return new Error(
303
+ `${identityResult.message}; cleanup failed: ${cleanupError.message}`,
304
+ { cause: identityResult },
305
+ )
306
+ }
307
+ return identityResult
245
308
  }
246
309
  return { directory: workspace.directory, workspaceId: workspace.id }
247
310
  }
@@ -256,6 +319,7 @@ async function tryWorkspaceCreate({
256
319
  * starterMessage is optional — if omitted, status edits are skipped (creation
257
320
  * still proceeds). This keeps worktree creation independent of Discord message
258
321
  * delivery, so a transient send failure never silently skips the worktree.
322
+ * beforeReady can bind a session before incoming messages may use the checkout.
259
323
  *
260
324
  * Returns the worktree directory on success, or an Error on failure.
261
325
  * Never throws — all internal errors are caught and returned as Error values.
@@ -265,21 +329,22 @@ export async function createWorktreeInBackground({
265
329
  starterMessage,
266
330
  worktreeName,
267
331
  projectDirectory,
268
- baseBranch,
332
+ baseCommit,
269
333
  rest,
334
+ beforeReady,
270
335
  }: {
271
336
  thread: ThreadChannel
272
337
  starterMessage?: Message
273
338
  worktreeName: string
274
339
  projectDirectory: string
275
- baseBranch?: string
340
+ baseCommit?: string
276
341
  rest: REST
342
+ beforeReady?: (workspace: {
343
+ directory: string
344
+ workspaceId: string
345
+ }) => Promise<void>
277
346
  }): Promise<string | Error> {
278
347
  return (async () => {
279
- logger.log(
280
- `Creating worktree "${worktreeName}" for project ${projectDirectory}${baseBranch ? ` from ${baseBranch}` : ''}`,
281
- )
282
-
283
348
  // Serialize status message edits so onProgress can't overwrite the
284
349
  // final success/error edit even if Discord's API is slow.
285
350
  let editChain: Promise<void> = Promise.resolve()
@@ -300,11 +365,26 @@ export async function createWorktreeInBackground({
300
365
  projectDirectory,
301
366
  })
302
367
 
368
+ const resolvedBaseCommit = baseCommit
369
+ ? baseCommit
370
+ : await resolveGitCommit({ directory: projectDirectory, ref: 'HEAD' })
371
+ if (resolvedBaseCommit instanceof Error) {
372
+ await setWorkspaceError({
373
+ threadId: thread.id,
374
+ errorMessage: resolvedBaseCommit.message,
375
+ })
376
+ editStatus(`🌳 **Worktree: ${worktreeName}**\n❌ ${resolvedBaseCommit.message}`)
377
+ await editChain
378
+ return resolvedBaseCommit
379
+ }
380
+ logger.log(
381
+ `Creating worktree "${worktreeName}" for project ${projectDirectory} from ${resolvedBaseCommit}`,
382
+ )
383
+
303
384
  const workspaceResult = await tryWorkspaceCreate({
304
- threadId: thread.id,
305
385
  worktreeName,
306
386
  projectDirectory,
307
- baseBranch,
387
+ baseCommit: resolvedBaseCommit,
308
388
  })
309
389
 
310
390
  if (workspaceResult instanceof Error) {
@@ -316,6 +396,20 @@ export async function createWorktreeInBackground({
316
396
  return workspaceResult
317
397
  }
318
398
 
399
+ const preparationResult = beforeReady
400
+ ? await beforeReady(workspaceResult).catch(
401
+ (e) => new WorktreeError('Failed to prepare worktree session', { cause: e }),
402
+ )
403
+ : undefined
404
+ if (preparationResult instanceof Error) {
405
+ logger.error('[WORKTREE] Session preparation failed:', preparationResult)
406
+ void notifyError(preparationResult, 'Worktree session preparation failed')
407
+ await sendThreadMessage(
408
+ thread,
409
+ `Worktree is ready, but failed to prepare its session: ${preparationResult.message}`,
410
+ )
411
+ }
412
+
319
413
  await setWorkspaceReady({
320
414
  threadId: thread.id,
321
415
  workspaceId: workspaceResult.workspaceId,
@@ -434,11 +528,11 @@ export async function handleNewWorktreeCommand({
434
528
  }
435
529
 
436
530
  // Parallelize: base branch validation and existing worktree check are independent
437
- const [baseBranch, existingWorktree] = await Promise.all([
531
+ const [baseCommit, existingWorktree] = await Promise.all([
438
532
  resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch }),
439
533
  findExistingWorktreePath({ projectDirectory, worktreeName }),
440
534
  ])
441
- if (baseBranch instanceof Error) {
535
+ if (baseCommit instanceof Error) {
442
536
  await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``)
443
537
  return
444
538
  }
@@ -489,7 +583,7 @@ export async function handleNewWorktreeCommand({
489
583
  starterMessage,
490
584
  worktreeName,
491
585
  projectDirectory,
492
- baseBranch,
586
+ baseCommit,
493
587
  rest: command.client.rest,
494
588
  }).catch((e) => {
495
589
  logger.error('[NEW-WORKTREE] Background error:', e)
@@ -543,12 +637,12 @@ async function handleWorktreeInThread({
543
637
  // Parallelize: base branch validation, existing worktree check, and parent channel
544
638
  // resolve are all independent. resolveTextChannel fetches the parent from Discord
545
639
  // cache/API which can overlap with the git operations.
546
- const [baseBranch, existingWorktreePath, textChannel] = await Promise.all([
640
+ const [baseCommit, existingWorktreePath, textChannel] = await Promise.all([
547
641
  resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch }),
548
642
  findExistingWorktreePath({ projectDirectory, worktreeName }),
549
643
  resolveTextChannel(thread),
550
644
  ])
551
- if (baseBranch instanceof Error) {
645
+ if (baseCommit instanceof Error) {
552
646
  await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``)
553
647
  return
554
648
  }
@@ -600,11 +694,9 @@ async function handleWorktreeInThread({
600
694
  starterMessage: statusMessage,
601
695
  worktreeName,
602
696
  projectDirectory,
603
- baseBranch,
697
+ baseCommit,
604
698
  rest: command.client.rest,
605
- })
606
- .then(async (result) => {
607
- if (result instanceof Error) return
699
+ beforeReady: async ({ directory, workspaceId }) => {
608
700
  const sourceSessionId = await getThreadSession(thread.id)
609
701
  if (!sourceSessionId) {
610
702
  await sendThreadMessage(
@@ -614,16 +706,7 @@ async function handleWorktreeInThread({
614
706
  return
615
707
  }
616
708
 
617
- const workspace = await getThreadWorktreeOrWorkspace(worktreeThread.id)
618
- if (!workspace?.workspace_id) {
619
- await sendThreadMessage(
620
- worktreeThread,
621
- '✗ Worktree is ready, but OpenCode returned no workspace ID for context reuse.',
622
- )
623
- return
624
- }
625
-
626
- const getClient = await initializeOpencodeForDirectory(result, {
709
+ const getClient = await initializeOpencodeForDirectory(directory, {
627
710
  originalRepoDirectory: projectDirectory,
628
711
  channelId: parent.id,
629
712
  })
@@ -637,8 +720,8 @@ async function handleWorktreeInThread({
637
720
 
638
721
  const forkResponse = await getClient().session.fork({
639
722
  sessionID: sourceSessionId,
640
- directory: result,
641
- workspace: workspace.workspace_id,
723
+ directory,
724
+ workspace: workspaceId,
642
725
  }).catch((e) => new OpenCodeSdkError({ operation: 'session.fork', cause: e }))
643
726
  if (forkResponse instanceof Error) {
644
727
  logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', forkResponse)
@@ -673,9 +756,9 @@ async function handleWorktreeInThread({
673
756
 
674
757
  const permissionResponse = await getClient().session.update({
675
758
  sessionID: forkedSession.id,
676
- directory: result,
759
+ directory,
677
760
  permission: buildSessionPermissions({
678
- directory: result,
761
+ directory,
679
762
  originalRepoDirectory: projectDirectory,
680
763
  }),
681
764
  }).catch((e) => new OpenCodeSdkError({ operation: 'session.update', cause: e }))
@@ -697,7 +780,7 @@ async function handleWorktreeInThread({
697
780
  threadId: worktreeThread.id,
698
781
  thread: worktreeThread,
699
782
  projectDirectory,
700
- sdkDirectory: result,
783
+ sdkDirectory: directory,
701
784
  channelId: parent.id,
702
785
  appId,
703
786
  })
@@ -705,11 +788,11 @@ async function handleWorktreeInThread({
705
788
  worktreeThread,
706
789
  `Reusing context from <#${thread.id}> in worktree session \`${forkedSession.id}\`.`,
707
790
  )
708
- })
709
- .catch((e) => {
710
- logger.error('[NEW-WORKTREE] Background error:', e)
711
- void notifyError(e, 'Background worktree creation failed (in-thread)')
712
- })
791
+ },
792
+ }).catch((e) => {
793
+ logger.error('[NEW-WORKTREE] Background error:', e)
794
+ void notifyError(e, 'Background worktree creation failed (in-thread)')
795
+ })
713
796
  }
714
797
 
715
798
  /**
@@ -24,6 +24,7 @@ import {
24
24
  registerHtmlAction,
25
25
  } from '../html-actions.js'
26
26
  import { createLogger, LogPrefix } from '../logger.js'
27
+ import { QUEUE_PREFIX } from '../message-formatting.js'
27
28
  import { store } from '../store.js'
28
29
 
29
30
  const logger = createLogger(LogPrefix.QUEUE)
@@ -157,7 +158,7 @@ export async function handleQueueCommand({
157
158
  return
158
159
  }
159
160
 
160
- const responseText = `» **${command.user.displayName}:** ${message.slice(0, 1000)}${message.length > 1000 ? '...' : ''}`
161
+ const responseText = `${QUEUE_PREFIX}**${command.user.displayName}:** ${message.slice(0, 1000)}${message.length > 1000 ? '...' : ''}`
161
162
  await command.reply({
162
163
  content: responseText,
163
164
  flags: SILENT_MESSAGE_FLAGS,
@@ -352,7 +353,7 @@ export async function handleQueueCommandCommand({
352
353
  return
353
354
  }
354
355
 
355
- const responseText = `» **${command.user.displayName}:** ${displayText}`
356
+ const responseText = `${QUEUE_PREFIX}**${command.user.displayName}:** ${displayText}`
356
357
  await command.reply({
357
358
  content: responseText,
358
359
  flags: SILENT_MESSAGE_FLAGS,
@@ -17,6 +17,7 @@ import {
17
17
  import { initializeOpencodeForDirectory } from '../opencode.js'
18
18
  import {
19
19
  sendThreadMessage,
20
+ sendSessionPartBatches,
20
21
  resolveProjectDirectoryFromAutocomplete,
21
22
  NOTIFY_MESSAGE_FLAGS,
22
23
  } from '../discord-utils.js'
@@ -135,12 +136,12 @@ export async function handleResumeCommand({
135
136
  }
136
137
 
137
138
  const batched = batchChunksForDiscord(chunks)
138
- for (const batch of batched) {
139
- const discordMessage = await sendThreadMessage(thread, batch.content)
139
+ const sent = await sendSessionPartBatches({ thread, batches: batched })
140
+ for (const batch of sent) {
140
141
  await setPartMessagesBatch(
141
142
  batch.partIds.map((partId) => ({
142
143
  partId,
143
- messageId: discordMessage.id,
144
+ messageId: batch.message.id,
144
145
  threadId: thread.id,
145
146
  })),
146
147
  )
@@ -2,6 +2,7 @@
2
2
  // Works in both text channels and threads. When used in a thread, the new
3
3
  // session inherits the same working directory (worktree/workspace) so the
4
4
  // user stays in the same folder context.
5
+ // Also owns the fresh-thread creation flow used by voice routing.
5
6
 
6
7
  import { ChannelType, type TextChannel, type ThreadChannel } from 'discord.js'
7
8
  import fs from 'node:fs'
@@ -15,6 +16,8 @@ import {
15
16
  } from '../database.js'
16
17
  import { initializeOpencodeForDirectory } from '../opencode.js'
17
18
  import {
19
+ sendThreadMessage,
20
+ splitMarkdownForDiscord,
18
21
  SILENT_MESSAGE_FLAGS,
19
22
  resolveProjectDirectoryFromAutocomplete,
20
23
  resolveWorkingDirectory,
@@ -22,10 +25,64 @@ import {
22
25
  } from '../discord-utils.js'
23
26
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
24
27
  import { createLogger, LogPrefix } from '../logger.js'
25
- import * as errore from 'errore'
28
+ import { DiscordOperationError } from '../errors.js'
26
29
 
27
30
  const logger = createLogger(LogPrefix.SESSION)
28
31
 
32
+ // Create only: callers confirm the destination before dispatching the full prompt.
33
+ export async function createNewSessionThread({
34
+ textChannel,
35
+ projectDirectory,
36
+ sourceWorkspace,
37
+ prompt,
38
+ files = [],
39
+ userId,
40
+ }: {
41
+ textChannel: TextChannel
42
+ projectDirectory: string
43
+ sourceWorkspace?: Awaited<ReturnType<typeof getThreadWorktreeOrWorkspace>>
44
+ prompt: string
45
+ files?: string[]
46
+ userId: string
47
+ }): Promise<ThreadChannel | Error> {
48
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
49
+ if (getClient instanceof Error) return getClient
50
+
51
+ const [starter, ...remaining] = splitMarkdownForDiscord({
52
+ content: `**Starting OpenCode session**\n${prompt}${files.length > 0 ? `\nFiles: ${files.join(', ')}` : ''}`,
53
+ maxLength: 2000,
54
+ })
55
+ if (!starter) return new Error('No session request to send. Retry with /new-session and a prompt.')
56
+ const starterMessage = await textChannel.send({ content: starter, flags: SILENT_MESSAGE_FLAGS })
57
+ const thread = await starterMessage.startThread({
58
+ name: prompt.slice(0, 100),
59
+ autoArchiveDuration: 1440,
60
+ reason: 'OpenCode session',
61
+ })
62
+ for (const chunk of remaining) {
63
+ await sendThreadMessage(thread, chunk)
64
+ }
65
+
66
+ // Persist the directory association for later commands and bot restarts.
67
+ if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
68
+ await createPendingWorkspace({
69
+ threadId: thread.id,
70
+ workspaceType: sourceWorkspace.workspace_type,
71
+ workspaceName: sourceWorkspace.workspace_name ?? '',
72
+ projectDirectory,
73
+ })
74
+ await setWorkspaceReady({
75
+ threadId: thread.id,
76
+ workspaceId: sourceWorkspace.workspace_id ?? undefined,
77
+ workspaceDirectory: sourceWorkspace.workspace_directory,
78
+ })
79
+ }
80
+ await thread.members.add(userId).catch((error) => {
81
+ logger.warn('Could not add session member:', error)
82
+ })
83
+ return thread
84
+ }
85
+
29
86
  export async function handleSessionCommand({
30
87
  command,
31
88
  appId,
@@ -90,77 +147,44 @@ export async function handleSessionCommand({
90
147
  return
91
148
  }
92
149
 
93
- try {
94
- const getClient = await initializeOpencodeForDirectory(projectDirectory)
95
- if (getClient instanceof Error) {
96
- await command.editReply(getClient.message)
97
- return
98
- }
99
-
100
- const files = filesString
101
- .split(',')
102
- .map((f) => f.trim())
103
- .filter((f) => f)
104
-
105
- let fullPrompt = prompt
106
- if (files.length > 0) {
107
- fullPrompt = `${prompt}\n\n@${files.join(' @')}`
108
- }
109
-
110
- const starterMessage = await textChannel.send({
111
- content: `**Starting OpenCode session**\n${prompt}${files.length > 0 ? `\nFiles: ${files.join(', ')}` : ''}`,
112
- flags: SILENT_MESSAGE_FLAGS,
113
- })
114
-
115
- const thread = await starterMessage.startThread({
116
- name: prompt.slice(0, 100),
117
- autoArchiveDuration: 1440,
118
- reason: 'OpenCode session',
119
- })
120
-
121
- // Persist workspace association so commands in the new thread resolve the
122
- // correct working directory, and the runtime survives bot restarts.
123
- if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
124
- await createPendingWorkspace({
125
- threadId: thread.id,
126
- workspaceType: sourceWorkspace.workspace_type,
127
- workspaceName: sourceWorkspace.workspace_name ?? '',
128
- projectDirectory,
129
- })
130
- await setWorkspaceReady({
131
- threadId: thread.id,
132
- workspaceId: sourceWorkspace.workspace_id ?? undefined,
133
- workspaceDirectory: sourceWorkspace.workspace_directory,
134
- })
135
- }
136
-
137
- // Add user to thread so it appears in their sidebar
138
- await thread.members.add(command.user.id)
139
-
140
- await command.editReply(`Created new session in ${thread.toString()}`)
150
+ const files = filesString
151
+ .split(',')
152
+ .map((f) => f.trim())
153
+ .filter((f) => f)
141
154
 
142
- const runtime = getOrCreateRuntime({
143
- threadId: thread.id,
144
- thread,
145
- projectDirectory,
146
- sdkDirectory,
147
- channelId: textChannel.id,
148
- appId,
149
- })
150
- await runtime.enqueueIncoming({
151
- prompt: fullPrompt,
152
- userId: command.user.id,
153
- username: command.user.displayName,
154
- agent,
155
- appId,
156
- mode: 'opencode',
157
- })
158
- } catch (error) {
159
- logger.error('[SESSION] Error:', error)
160
- await command.editReply(
161
- `Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`,
162
- )
155
+ const thread = await createNewSessionThread({
156
+ textChannel,
157
+ projectDirectory,
158
+ sourceWorkspace,
159
+ prompt,
160
+ files,
161
+ userId: command.user.id,
162
+ }).catch((cause) => new DiscordOperationError({ operation: 'createSessionThread', cause }))
163
+ if (thread instanceof Error) {
164
+ logger.error('New session creation failed:', thread)
165
+ await command.editReply(`${thread.message}. Retry with /new-session.`)
166
+ return
163
167
  }
168
+ await command.editReply(`Created new session in ${thread.toString()}`)
169
+ const runtime = getOrCreateRuntime({
170
+ threadId: thread.id,
171
+ thread,
172
+ projectDirectory,
173
+ sdkDirectory,
174
+ channelId: textChannel.id,
175
+ appId,
176
+ })
177
+ await runtime.enqueueIncoming({
178
+ prompt: files.length > 0 ? `${prompt}\n\n@${files.join(' @')}` : prompt,
179
+ userId: command.user.id,
180
+ username: command.user.displayName,
181
+ agent,
182
+ appId,
183
+ mode: 'opencode',
184
+ }).catch(async (error) => {
185
+ logger.error('New session dispatch failed:', error)
186
+ await sendThreadMessage(thread, 'Could not send the request to OpenCode. Send your request again in this thread.')
187
+ })
164
188
  }
165
189
 
166
190
  async function handleAgentAutocomplete({
@@ -213,12 +213,12 @@ export const handleUserCommand: CommandHandler = async ({
213
213
  })
214
214
  }
215
215
 
216
- const sessionDirectory = await (async () => {
217
- if (!worktreePromise) return projectDirectory
218
- const result = await worktreePromise
219
- if (result instanceof Error) return projectDirectory
220
- return result
221
- })()
216
+ const worktreeResult = worktreePromise ? await worktreePromise : projectDirectory
217
+ if (worktreeResult instanceof Error) {
218
+ await command.editReply(`Worktree creation failed: ${worktreeResult.message}`)
219
+ return
220
+ }
221
+ const sessionDirectory = worktreeResult
222
222
 
223
223
  await command.editReply(
224
224
  `Started /${commandName} in ${newThread.toString()}`,
@@ -28,13 +28,13 @@ import {
28
28
  cancelHtmlActionsForOwner,
29
29
  registerHtmlAction,
30
30
  } from '../html-actions.js'
31
- import * as errore from 'errore'
32
31
  import crypto from 'node:crypto'
33
- import { GitCommandError, OpenCodeSdkError } from '../errors.js'
32
+ import { OpenCodeSdkError } from '../errors.js'
34
33
  import { resolveTextChannel, resolveWorkingDirectory } from '../discord-utils.js'
35
34
  import { initializeOpencodeForDirectory } from '../opencode.js'
36
35
  import {
37
36
  deleteWorktree,
37
+ extractGitExecOutput,
38
38
  git,
39
39
  getDefaultBranch,
40
40
  listGitWorktrees,
@@ -42,15 +42,9 @@ import {
42
42
  } from '../worktrees.js'
43
43
  import path from 'node:path'
44
44
 
45
- // Extracts the git stderr from a deleteWorktree error via errore.findCause.
46
- // Chain: Error { cause: GitCommandError { cause: CommandError { stderr } } }.
47
45
  export function extractGitStderr(error: Error): string | undefined {
48
- const gitErr = errore.findCause(error, GitCommandError)
49
- const stderr = (gitErr?.cause as { stderr?: string } | undefined)?.stderr?.trim()
50
- if (stderr && stderr.length > 0) {
51
- return stderr
52
- }
53
- return undefined
46
+ const stderr = extractGitExecOutput(error).stderr
47
+ return stderr.length > 0 ? stderr : undefined
54
48
  }
55
49
 
56
50
  export function formatTimeAgo(date: Date): string {
@@ -152,8 +146,7 @@ async function getWorktreeGitStatus({
152
146
  defaultBranch: string
153
147
  }): Promise<WorktreeGitStatus | null> {
154
148
  try {
155
- // Use raw git calls so errors/timeouts are visible — isDirty() swallows
156
- // errors and returns false, which would render "merged" instead of "unknown".
149
+ // Use raw git calls so status and ahead-count share one timeout window.
157
150
  const [statusResult, aheadResult] = await Promise.all([
158
151
  git(directory, 'status --porcelain', { timeout: GIT_CMD_TIMEOUT }),
159
152
  git(directory, `rev-list --count "${defaultBranch}..HEAD"`, {