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
@@ -4,7 +4,7 @@ import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import type { CommandContext, AutocompleteContext } from './types.js'
6
6
  import {
7
- findChannelsByDirectory,
7
+ findRegisteredTextChannelForDirectory,
8
8
  getAllTextChannelDirectories,
9
9
  } from '../database.js'
10
10
  import { initializeOpencodeForDirectory } from '../opencode.js'
@@ -56,14 +56,10 @@ export async function handleAddProjectCommand({
56
56
  return
57
57
  }
58
58
 
59
- const existingChannels = await findChannelsByDirectory({
60
- directory,
61
- channelType: 'text',
62
- })
63
-
64
- if (existingChannels.length > 0) {
59
+ const existingChannel = await findRegisteredTextChannelForDirectory(directory)
60
+ if (existingChannel) {
65
61
  await command.editReply(
66
- `A channel already exists for this directory: <#${existingChannels[0]!.channel_id}>`,
62
+ `A channel already exists for this directory: <#${existingChannel.channel_id}>`,
67
63
  )
68
64
  return
69
65
  }
@@ -2,6 +2,7 @@
2
2
  // Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
3
3
  // When a prompt is provided to a quick agent command (e.g. /plan-agent "fix the bug"),
4
4
  // the prompt is sent with that agent and the session keeps that agent afterwards.
5
+ // Optional last `variant` option sets the model thinking level for that agent.
5
6
 
6
7
  import {
7
8
  ChatInputCommandInteraction,
@@ -18,6 +19,9 @@ import crypto from 'node:crypto'
18
19
  import {
19
20
  setChannelAgent,
20
21
  setSessionAgent,
22
+ setSessionModel,
23
+ setChannelModel,
24
+ getChannelModel,
21
25
  clearSessionModel,
22
26
  getThreadSession,
23
27
  getSessionAgent,
@@ -28,10 +32,15 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
28
32
  import {
29
33
  resolveTextChannel,
30
34
  resolveWorkingDirectory,
35
+ resolveProjectDirectoryFromAutocomplete,
31
36
  getKimakiMetadata,
32
37
  SILENT_MESSAGE_FLAGS,
33
38
  } from '../discord-utils.js'
34
- import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
39
+ import {
40
+ getOrCreateRuntime,
41
+ releaseCurrentThreadIngress,
42
+ waitForCurrentThreadIngress,
43
+ } from '../session-handler/thread-session-runtime.js'
35
44
  import { createLogger, LogPrefix } from '../logger.js'
36
45
  import {
37
46
  formatModelSource,
@@ -45,7 +54,14 @@ import {
45
54
  worktreeCreatingMessage,
46
55
  } from './new-worktree.js'
47
56
  import { WORKTREE_PREFIX } from './merge-worktree.js'
57
+ import { QUEUE_PREFIX } from '../message-formatting.js'
48
58
  import { store } from '../store.js'
59
+ import {
60
+ getThinkingValuesForModel,
61
+ resolveRequestedThinkingVariant,
62
+ } from '../thinking-utils.js'
63
+ import type { AutocompleteContext } from './types.js'
64
+ import { OpenCodeSdkError } from '../errors.js'
49
65
 
50
66
  const agentLogger = createLogger(LogPrefix.AGENT)
51
67
 
@@ -269,6 +285,65 @@ export async function setAgentForContext({
269
285
  }
270
286
  }
271
287
 
288
+ async function applyQuickAgentVariant({
289
+ context,
290
+ agentName,
291
+ variant,
292
+ }: {
293
+ context: AgentCommandContext
294
+ agentName: string
295
+ variant: string
296
+ }): Promise<string | undefined> {
297
+ const modelInfo = await resolveAgentModelInfo({ context, agentName })
298
+ if (modelInfo.type === 'none') {
299
+ agentLogger.warn(
300
+ `[AGENT] Skipping variant ${variant}: no model for agent ${agentName}`,
301
+ )
302
+ return undefined
303
+ }
304
+
305
+ const getClient = await initializeOpencodeForDirectory(context.dir)
306
+ if (getClient instanceof Error) return undefined
307
+
308
+ const providersResponse = await getClient()
309
+ .provider.list({ directory: context.workingDirectory })
310
+ .catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e }))
311
+ if (providersResponse instanceof Error || !providersResponse.data) {
312
+ agentLogger.warn(`[AGENT] Skipping variant ${variant}: provider list failed`)
313
+ return undefined
314
+ }
315
+
316
+ const matchedVariant = resolveRequestedThinkingVariant({
317
+ requestedValue: variant,
318
+ providers: providersResponse.data.all,
319
+ providerId: modelInfo.providerID,
320
+ modelId: modelInfo.modelID,
321
+ })
322
+ if (!matchedVariant) {
323
+ agentLogger.warn(
324
+ `[AGENT] Skipping variant ${variant}: not supported by ${modelInfo.model}`,
325
+ )
326
+ return undefined
327
+ }
328
+
329
+ if (context.isThread && context.sessionId) {
330
+ await setSessionModel({
331
+ sessionId: context.sessionId,
332
+ modelId: modelInfo.model,
333
+ variant: matchedVariant,
334
+ })
335
+ return matchedVariant
336
+ }
337
+
338
+ const existing = await getChannelModel(context.channelId)
339
+ await setChannelModel({
340
+ channelId: context.channelId,
341
+ modelId: existing?.modelId ?? modelInfo.model,
342
+ variant: matchedVariant,
343
+ })
344
+ return matchedVariant
345
+ }
346
+
272
347
  function formatAgentModelLine(modelInfo: CurrentModelInfo): string {
273
348
  if (modelInfo.type === 'none') return ''
274
349
  const source = formatModelSource({
@@ -287,21 +362,24 @@ function formatAgentPreferenceReply({
287
362
  previousAgentName,
288
363
  modelInfo,
289
364
  scope,
365
+ variant,
290
366
  }: {
291
367
  agentName: string
292
368
  previousAgentName?: string
293
369
  modelInfo: CurrentModelInfo
294
370
  scope: 'session' | 'channel'
371
+ variant?: string
295
372
  }): string {
296
373
  const sameAgent = previousAgentName === agentName
297
374
  const verb = sameAgent ? 'Using' : 'Switched to'
298
375
  const previousText =
299
376
  !sameAgent && previousAgentName ? ` (was **${previousAgentName}**)` : ''
300
377
  const modelText = formatAgentModelLine(modelInfo)
378
+ const variantText = variant ? `\nVariant: **${variant}**` : ''
301
379
  if (scope === 'session') {
302
- return `${verb} **${agentName}** agent for this session${previousText}${modelText}\nThe agent will change on the next message.`
380
+ return `${verb} **${agentName}** agent for this session${previousText}${modelText}${variantText}\nThe agent will change on the next message.`
303
381
  }
304
- return `${verb} **${agentName}** agent for this channel${previousText}${modelText}\nAll new sessions will use this agent.`
382
+ return `${verb} **${agentName}** agent for this channel${previousText}${modelText}${variantText}\nAll new sessions will use this agent.`
305
383
  }
306
384
 
307
385
  async function resolveAgentModelInfo({
@@ -452,7 +530,12 @@ export async function handleAgentSelectMenu(
452
530
  const previousAgentName =
453
531
  previousAgent.type !== 'none' ? previousAgent.agent : undefined
454
532
 
455
- await setAgentForContext({ context, agentName: selectedAgent })
533
+ await waitForCurrentThreadIngress()
534
+ try {
535
+ await setAgentForContext({ context, agentName: selectedAgent })
536
+ } finally {
537
+ releaseCurrentThreadIngress()
538
+ }
456
539
  const modelInfo = await resolveAgentModelInfo({
457
540
  context,
458
541
  agentName: selectedAgent,
@@ -498,10 +581,17 @@ export async function handleQuickAgentCommand({
498
581
  }): Promise<void> {
499
582
  const fallbackAgentName = command.commandName.replace(/-agent$/, '')
500
583
  const prompt = command.options.getString('prompt') || undefined
584
+ const variant = command.options.getString('variant') || undefined
501
585
 
502
586
  // Prompt mode: send the prompt with this agent immediately.
503
587
  if (prompt) {
504
- return handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt })
588
+ return handleQuickAgentWithPrompt({
589
+ command,
590
+ appId,
591
+ fallbackAgentName,
592
+ prompt,
593
+ variant,
594
+ })
505
595
  }
506
596
 
507
597
  // No prompt: switch the persistent agent preference (original behavior).
@@ -529,23 +619,39 @@ export async function handleQuickAgentCommand({
529
619
  const previousAgentName =
530
620
  previousAgent.type !== 'none' ? previousAgent.agent : undefined
531
621
 
532
- await setAgentForContext({ context, agentName: resolvedAgentName })
533
-
534
- const modelInfo = await resolveAgentModelInfo({
535
- context,
536
- agentName: resolvedAgentName,
537
- })
538
- const scope =
539
- context.isThread && context.sessionId ? 'session' : 'channel'
540
-
541
- await command.editReply({
542
- content: formatAgentPreferenceReply({
622
+ await waitForCurrentThreadIngress()
623
+ try {
624
+ await setAgentForContext({ context, agentName: resolvedAgentName })
625
+ const modelInfo = await resolveAgentModelInfo({
626
+ context,
543
627
  agentName: resolvedAgentName,
544
- previousAgentName,
545
- modelInfo,
546
- scope,
547
- }),
548
- })
628
+ })
629
+ const appliedVariant = variant
630
+ ? await applyQuickAgentVariant({
631
+ context,
632
+ agentName: resolvedAgentName,
633
+ variant,
634
+ })
635
+ : undefined
636
+ const scope =
637
+ context.isThread && context.sessionId ? 'session' : 'channel'
638
+ const unsupportedVariantNote =
639
+ variant && !appliedVariant
640
+ ? `\nVariant **${variant}** is not supported by this agent's model.`
641
+ : ''
642
+
643
+ await command.editReply({
644
+ content: `${formatAgentPreferenceReply({
645
+ agentName: resolvedAgentName,
646
+ previousAgentName,
647
+ modelInfo,
648
+ scope,
649
+ variant: appliedVariant,
650
+ })}${unsupportedVariantNote}`,
651
+ })
652
+ } finally {
653
+ releaseCurrentThreadIngress()
654
+ }
549
655
  } catch (error) {
550
656
  agentLogger.error('Error in quick agent command:', error)
551
657
  await command.editReply({
@@ -565,11 +671,13 @@ async function handleQuickAgentWithPrompt({
565
671
  appId,
566
672
  fallbackAgentName,
567
673
  prompt,
674
+ variant,
568
675
  }: {
569
676
  command: ChatInputCommandInteraction
570
677
  appId: string
571
678
  fallbackAgentName: string
572
679
  prompt: string
680
+ variant?: string
573
681
  }): Promise<void> {
574
682
  const channel = command.channel
575
683
  if (!channel) {
@@ -615,7 +723,7 @@ async function handleQuickAgentWithPrompt({
615
723
 
616
724
  // Visible reply showing the one-shot prompt (not ephemeral, so it appears in thread).
617
725
  await command.reply({
618
- content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
726
+ content: `${QUEUE_PREFIX}**${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
619
727
  flags: SILENT_MESSAGE_FLAGS,
620
728
  })
621
729
 
@@ -626,6 +734,7 @@ async function handleQuickAgentWithPrompt({
626
734
  agent: resolvedAgentName,
627
735
  appId,
628
736
  mode: 'opencode',
737
+ variant,
629
738
  })
630
739
  } else if (channel.type === ChannelType.GuildText) {
631
740
  // In a channel: create a new thread and enqueue with the requested agent.
@@ -662,7 +771,7 @@ async function handleQuickAgentWithPrompt({
662
771
  : baseThreadName
663
772
 
664
773
  const starterMessage = await channel.send({
665
- content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
774
+ content: `${QUEUE_PREFIX}**${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
666
775
  flags: SILENT_MESSAGE_FLAGS,
667
776
  })
668
777
 
@@ -696,12 +805,12 @@ async function handleQuickAgentWithPrompt({
696
805
  })
697
806
  }
698
807
 
699
- const sessionDirectory = await (async () => {
700
- if (!worktreePromise) return projectDirectory
701
- const result = await worktreePromise
702
- if (result instanceof Error) return projectDirectory
703
- return result
704
- })()
808
+ const worktreeResult = worktreePromise ? await worktreePromise : projectDirectory
809
+ if (worktreeResult instanceof Error) {
810
+ await command.editReply(`Worktree creation failed: ${worktreeResult.message}`)
811
+ return
812
+ }
813
+ const sessionDirectory = worktreeResult
705
814
 
706
815
  await command
707
816
  .editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`)
@@ -725,6 +834,7 @@ async function handleQuickAgentWithPrompt({
725
834
  agent: resolvedAgentName,
726
835
  appId,
727
836
  mode: 'opencode',
837
+ variant,
728
838
  })
729
839
  } else {
730
840
  await command.reply({
@@ -733,3 +843,69 @@ async function handleQuickAgentWithPrompt({
733
843
  })
734
844
  }
735
845
  }
846
+
847
+ export async function handleQuickAgentAutocomplete({
848
+ interaction,
849
+ }: AutocompleteContext): Promise<void> {
850
+ if (interaction.options.getFocused(true).name !== 'variant') {
851
+ await interaction.respond([])
852
+ return
853
+ }
854
+
855
+ const focusedValue = interaction.options.getFocused().trim().toLowerCase()
856
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction)
857
+ if (!projectDirectory) {
858
+ await interaction.respond([])
859
+ return
860
+ }
861
+
862
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
863
+ if (getClient instanceof Error) {
864
+ await interaction.respond([])
865
+ return
866
+ }
867
+
868
+ const [providersResponse, agentsResponse] = await Promise.all([
869
+ getClient()
870
+ .provider.list({ directory: projectDirectory })
871
+ .catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e })),
872
+ getClient()
873
+ .app.agents({ directory: projectDirectory })
874
+ .catch((e) => new OpenCodeSdkError({ operation: 'app.agents', cause: e })),
875
+ ])
876
+ if (providersResponse instanceof Error || agentsResponse instanceof Error) {
877
+ agentLogger.warn('[AUTOCOMPLETE] Failed to fetch variant choices')
878
+ await interaction.respond([])
879
+ return
880
+ }
881
+ if (!providersResponse.data || !agentsResponse.data) {
882
+ await interaction.respond([])
883
+ return
884
+ }
885
+
886
+ const resolvedAgentName =
887
+ parseQuickAgentNameFromDescription(interaction.command?.description) ||
888
+ interaction.commandName.replace(/-agent$/, '')
889
+ const agent = agentsResponse.data.find((candidate) => candidate.name === resolvedAgentName)
890
+ const agentModel = agent?.model
891
+ if (!agentModel) {
892
+ await interaction.respond([])
893
+ return
894
+ }
895
+
896
+ const variants = getThinkingValuesForModel({
897
+ providers: providersResponse.data.all,
898
+ providerId: agentModel.providerID,
899
+ modelId: agentModel.modelID,
900
+ }).filter((variant) => {
901
+ if (!focusedValue) return true
902
+ return variant.toLowerCase().includes(focusedValue)
903
+ })
904
+
905
+ await interaction.respond(
906
+ variants.slice(0, 25).map((variant) => ({
907
+ name: variant.slice(0, 100),
908
+ value: variant,
909
+ })),
910
+ )
911
+ }
@@ -9,8 +9,11 @@ import {
9
9
  showAskUserQuestionDropdowns,
10
10
  } from './ask-question.js'
11
11
 
12
- function createFakeThread(): ThreadChannel {
12
+ function createFakeThread({ failSend }: { failSend?: boolean } = {}): ThreadChannel {
13
13
  const send = vi.fn(async () => {
14
+ if (failSend) {
15
+ throw new Error('Missing Permissions')
16
+ }
14
17
  return { id: 'msg-1' }
15
18
  })
16
19
 
@@ -109,6 +112,50 @@ describe('ask-question', () => {
109
112
  expect([...pendingQuestionContexts.keys()]).toEqual(['ctx-3'])
110
113
  })
111
114
 
115
+ test('does not schedule a question expiry timer', async () => {
116
+ const timeoutSpy = vi.spyOn(globalThis, 'setTimeout')
117
+
118
+ await showAskUserQuestionDropdowns({
119
+ thread: createFakeThread(),
120
+ sessionId: 'ses-1',
121
+ directory: '/project',
122
+ requestId: 'req-no-ttl',
123
+ input: {
124
+ questions: [{
125
+ question: 'Choose one',
126
+ header: 'Pick',
127
+ options: [
128
+ { label: 'Alpha', description: 'A' },
129
+ { label: 'Beta', description: 'B' },
130
+ ],
131
+ }],
132
+ },
133
+ })
134
+
135
+ expect(timeoutSpy).not.toHaveBeenCalled()
136
+ })
137
+
138
+ test('drops the context when the dropdown message fails to send', async () => {
139
+ await showAskUserQuestionDropdowns({
140
+ thread: createFakeThread({ failSend: true }),
141
+ sessionId: 'ses-1',
142
+ directory: '/project',
143
+ requestId: 'req-send-fails',
144
+ input: {
145
+ questions: [{
146
+ question: 'Choose one',
147
+ header: 'Pick',
148
+ options: [
149
+ { label: 'Alpha', description: 'A' },
150
+ { label: 'Beta', description: 'B' },
151
+ ],
152
+ }],
153
+ },
154
+ })
155
+
156
+ expect(pendingQuestionContexts.size).toBe(0)
157
+ })
158
+
112
159
  test('requires every question to have an answer', () => {
113
160
  expect(areAllQuestionsAnswered({
114
161
  totalQuestions: 3,
@@ -12,7 +12,9 @@ import {
12
12
  import crypto from 'node:crypto'
13
13
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
14
14
  import { getOpencodeClient } from '../opencode.js'
15
+ import { DiscordOperationError } from '../errors.js'
15
16
  import { createLogger, LogPrefix } from '../logger.js'
17
+ import { QUEUE_PREFIX } from '../message-formatting.js'
16
18
 
17
19
  const logger = createLogger(LogPrefix.ASK_QUESTION)
18
20
 
@@ -44,8 +46,9 @@ type PendingQuestionContext = {
44
46
  }
45
47
 
46
48
  // Store pending question contexts by hash.
47
- // TTL prevents unbounded growth if user never answers a question.
48
- const QUESTION_CONTEXT_TTL_MS = 10 * 60 * 1000
49
+ // No TTL on purpose: a question can only be answered by the user, so expiring it
50
+ // could only abort the run. Cleanup happens on answer, on a new user message, on
51
+ // abort (abortActiveRunInternal), and on thread dispose.
49
52
  export const pendingQuestionContexts = new Map<string, PendingQuestionContext>()
50
53
 
51
54
  export function areAllQuestionsAnswered({
@@ -157,32 +160,6 @@ export async function showAskUserQuestionDropdowns({
157
160
  }
158
161
 
159
162
  pendingQuestionContexts.set(contextHash, context)
160
- // On TTL expiry: hide the dropdown UI and abort the session so OpenCode
161
- // unblocks. We intentionally do NOT call question.reply() — sending 'Other'
162
- // made the model think the user chose an option when they didn't.
163
- setTimeout(async () => {
164
- const ctx = pendingQuestionContexts.get(contextHash)
165
- if (!ctx) {
166
- return
167
- }
168
- // Delete context first so the dropdown becomes inert immediately.
169
- // Without this, a user clicking during the abort() await would still
170
- // be accepted by handleAskQuestionSelectMenu, then abort() would
171
- // kill that valid run.
172
- deletePendingQuestionContextsForRequest({
173
- threadId: ctx.thread.id,
174
- requestId: ctx.requestId,
175
- })
176
- // Abort the session so OpenCode isn't stuck waiting for a reply
177
- const client = getOpencodeClient(ctx.directory)
178
- if (client) {
179
- await client.session.abort({
180
- sessionID: ctx.sessionId,
181
- }).catch((error) => {
182
- logger.error('Failed to abort session after question expiry:', error)
183
- })
184
- }
185
- }, QUESTION_CONTEXT_TTL_MS).unref()
186
163
 
187
164
  // Send one message per question with its dropdown directly underneath
188
165
  for (let i = 0; i < input.questions.length; i++) {
@@ -219,11 +196,24 @@ export async function showAskUserQuestionDropdowns({
219
196
  const actionRow =
220
197
  new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
221
198
 
222
- await thread.send({
199
+ const sendResult = await thread.send({
223
200
  content: `**${(q.header || '').slice(0, 200)}**\n${q.question.slice(0, 1700)}`,
224
201
  components: [actionRow],
225
202
  flags: silent ? SILENT_MESSAGE_FLAGS : NOTIFY_MESSAGE_FLAGS,
226
- })
203
+ }).catch((e) => new DiscordOperationError({ operation: 'sendQuestionDropdown', cause: e }))
204
+ // Without a visible dropdown the user can never answer, and there is no TTL
205
+ // to rescue the run, so drop the context and abort so OpenCode unblocks.
206
+ if (sendResult instanceof Error) {
207
+ logger.error('Failed to send question dropdown:', sendResult)
208
+ deletePendingQuestionContextsForRequest({ threadId: thread.id, requestId })
209
+ const client = getOpencodeClient(directory)
210
+ if (client) {
211
+ await client.session.abort({ sessionID: sessionId }).catch((error) => {
212
+ logger.error('Failed to abort session after question send failure:', error)
213
+ })
214
+ }
215
+ return
216
+ }
227
217
  }
228
218
 
229
219
  logger.log(
@@ -305,7 +295,7 @@ export async function handleAskQuestionSelectMenu(
305
295
  const username = interaction.user.globalName || interaction.user.username
306
296
  await sendThreadMessage(
307
297
  context.thread,
308
- `» **${username}:** ${answeredText}`,
298
+ `${QUEUE_PREFIX}**${username}:** ${answeredText}`,
309
299
  )
310
300
 
311
301
  // Check if all questions are answered
@@ -414,7 +404,7 @@ export function parseAskUserQuestionTool(part: {
414
404
  * the tool answer so the model sees the user's response. The caller should
415
405
  * NOT also enqueue the message as a new prompt.
416
406
  * Returns 'replied' on success, 'reply-failed' if the reply call fails
417
- * (context kept pending so TTL can retry).
407
+ * (context kept pending so the user can retry).
418
408
  */
419
409
  export async function cancelPendingQuestion(
420
410
  threadId: string,
@@ -466,7 +456,7 @@ export async function cancelPendingQuestion(
466
456
  logger.log(`Answered question ${context.requestId} with user message`)
467
457
  } catch (error) {
468
458
  logger.error('Failed to answer question:', error)
469
- // Keep context pending so TTL can still fire.
459
+ // Keep context pending so the user can retry.
470
460
  // Caller should not consume the user message since reply failed.
471
461
  return 'reply-failed'
472
462
  }
@@ -9,7 +9,13 @@ import {
9
9
  type ThreadChannel,
10
10
  MessageFlags,
11
11
  } from 'discord.js'
12
- import { getThreadSession, setThreadSession } from '../database.js'
12
+ import {
13
+ getThreadSession,
14
+ setThreadSession,
15
+ getThreadWorktreeOrWorkspace,
16
+ createPendingWorkspace,
17
+ setWorkspaceReady,
18
+ } from '../database.js'
13
19
  import {
14
20
  resolveWorkingDirectory,
15
21
  resolveTextChannel,
@@ -20,6 +26,7 @@ import { createLogger, LogPrefix } from '../logger.js'
20
26
  import type { CommandContext } from './types.js'
21
27
  import { initializeOpencodeForDirectory } from '../opencode.js'
22
28
  import { copyCurrentSessionModel } from './model.js'
29
+ import type { DiscordFileAttachment } from '../message-formatting.js'
23
30
 
24
31
  const logger = createLogger(LogPrefix.FORK)
25
32
 
@@ -28,18 +35,24 @@ export async function forkSessionToBtwThread({
28
35
  projectDirectory,
29
36
  sdkDirectory,
30
37
  prompt,
38
+ modelPrompt = prompt,
31
39
  userId,
32
40
  username,
33
41
  appId,
42
+ agent,
43
+ images,
34
44
  }: {
35
45
  sourceThread: ThreadChannel
36
46
  projectDirectory: string
37
47
  /** Worktree directory when forking from a worktree thread, otherwise same as projectDirectory */
38
48
  sdkDirectory: string
39
49
  prompt: string
50
+ modelPrompt?: string
40
51
  userId: string
41
52
  username: string
42
53
  appId: string | undefined
54
+ agent?: string
55
+ images?: DiscordFileAttachment[]
43
56
  }): Promise<{ thread: ThreadChannel; forkedSessionId: string } | Error> {
44
57
  // Parallelize: session lookup + opencode init + parent channel resolve are independent
45
58
  const [sessionId, getClientResult, textChannel] = await Promise.all([
@@ -61,7 +74,7 @@ export async function forkSessionToBtwThread({
61
74
  }
62
75
 
63
76
  // Fork must succeed before creating the Discord thread to avoid orphan threads
64
- const forkResponse = await getClientResult().session.fork({ sessionID: sessionId })
77
+ const forkResponse = await getClientResult().session.fork({ sessionID: sessionId, directory: sdkDirectory })
65
78
  if (!forkResponse.data) {
66
79
  return new Error('Failed to fork session')
67
80
  }
@@ -74,7 +87,7 @@ export async function forkSessionToBtwThread({
74
87
  channelId,
75
88
  appId,
76
89
  getClient: getClientResult,
77
- directory: projectDirectory,
90
+ directory: sdkDirectory,
78
91
  })
79
92
 
80
93
  const thread = await textChannel.threads.create({
@@ -85,11 +98,27 @@ export async function forkSessionToBtwThread({
85
98
 
86
99
  // DB mapping must complete before user-visible actions so the thread is routable
87
100
  await setThreadSession(thread.id, forkedSession.id)
101
+ const sourceWorkspace = await getThreadWorktreeOrWorkspace(sourceThread.id)
102
+ if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
103
+ await createPendingWorkspace({
104
+ threadId: thread.id,
105
+ workspaceType: sourceWorkspace.workspace_type,
106
+ workspaceName: sourceWorkspace.workspace_name ?? '',
107
+ projectDirectory,
108
+ })
109
+ await setWorkspaceReady({
110
+ threadId: thread.id,
111
+ workspaceId: sourceWorkspace.workspace_id ?? undefined,
112
+ workspaceDirectory: sourceWorkspace.workspace_directory,
113
+ })
114
+ }
88
115
 
89
116
  // Parallelize: member add and status message are independent best-effort actions
90
117
  const sourceThreadLink = `<#${sourceThread.id}>`
91
118
  await Promise.all([
92
- thread.members.add(userId).catch(() => {}),
119
+ thread.members.add(userId).catch((error) => {
120
+ logger.warn('Could not add fork member:', error)
121
+ }),
93
122
  sendThreadMessage(
94
123
  thread,
95
124
  `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`,
@@ -111,7 +140,7 @@ export async function forkSessionToBtwThread({
111
140
  `Parent session: ${sessionId} (thread <#${sourceThread.id}>)`,
112
141
  `Do NOT send messages to the parent session unless the user explicitly asks you to.`,
113
142
  ``,
114
- prompt,
143
+ modelPrompt,
115
144
  ].join('\n')
116
145
 
117
146
  const runtime = getOrCreateRuntime({
@@ -121,13 +150,19 @@ export async function forkSessionToBtwThread({
121
150
  sdkDirectory,
122
151
  channelId,
123
152
  appId,
153
+ sessionId: forkedSession.id,
124
154
  })
125
155
  await runtime.enqueueIncoming({
126
156
  prompt: wrappedPrompt,
157
+ agent,
158
+ images,
127
159
  userId,
128
160
  username,
129
161
  appId,
130
162
  mode: 'opencode',
163
+ }).catch(async (error) => {
164
+ logger.error('Fork dispatch failed:', error)
165
+ await sendThreadMessage(thread, 'Could not send the request to OpenCode. Send your request again in this thread.')
131
166
  })
132
167
 
133
168
  return {