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
@@ -2,18 +2,22 @@
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
  import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
6
7
  import crypto from 'node:crypto';
7
- import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, getChannelWorktreesEnabled, } from '../database.js';
8
+ import { setChannelAgent, setSessionAgent, setSessionModel, setChannelModel, getChannelModel, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, getChannelWorktreesEnabled, } from '../database.js';
8
9
  import { initializeOpencodeForDirectory } from '../opencode.js';
9
- import { resolveTextChannel, resolveWorkingDirectory, getKimakiMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
10
- import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
10
+ import { resolveTextChannel, resolveWorkingDirectory, resolveProjectDirectoryFromAutocomplete, getKimakiMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
11
+ import { getOrCreateRuntime, releaseCurrentThreadIngress, waitForCurrentThreadIngress, } from '../session-handler/thread-session-runtime.js';
11
12
  import { createLogger, LogPrefix } from '../logger.js';
12
13
  import { formatModelSource, getCurrentModelInfo, } from './model.js';
13
14
  import { isGitRepositoryRoot } from '../worktrees.js';
14
15
  import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage, } from './new-worktree.js';
15
16
  import { WORKTREE_PREFIX } from './merge-worktree.js';
17
+ import { QUEUE_PREFIX } from '../message-formatting.js';
16
18
  import { store } from '../store.js';
19
+ import { getThinkingValuesForModel, resolveRequestedThinkingVariant, } from '../thinking-utils.js';
20
+ import { OpenCodeSdkError } from '../errors.js';
17
21
  const agentLogger = createLogger(LogPrefix.AGENT);
18
22
  const AGENT_CONTEXT_TTL_MS = 10 * 60 * 1000;
19
23
  const pendingAgentContexts = new Map();
@@ -161,6 +165,48 @@ export async function setAgentForContext({ context, agentName, }) {
161
165
  agentLogger.log(`Set agent ${agentName} for channel ${context.channelId}`);
162
166
  }
163
167
  }
168
+ async function applyQuickAgentVariant({ context, agentName, variant, }) {
169
+ const modelInfo = await resolveAgentModelInfo({ context, agentName });
170
+ if (modelInfo.type === 'none') {
171
+ agentLogger.warn(`[AGENT] Skipping variant ${variant}: no model for agent ${agentName}`);
172
+ return undefined;
173
+ }
174
+ const getClient = await initializeOpencodeForDirectory(context.dir);
175
+ if (getClient instanceof Error)
176
+ return undefined;
177
+ const providersResponse = await getClient()
178
+ .provider.list({ directory: context.workingDirectory })
179
+ .catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e }));
180
+ if (providersResponse instanceof Error || !providersResponse.data) {
181
+ agentLogger.warn(`[AGENT] Skipping variant ${variant}: provider list failed`);
182
+ return undefined;
183
+ }
184
+ const matchedVariant = resolveRequestedThinkingVariant({
185
+ requestedValue: variant,
186
+ providers: providersResponse.data.all,
187
+ providerId: modelInfo.providerID,
188
+ modelId: modelInfo.modelID,
189
+ });
190
+ if (!matchedVariant) {
191
+ agentLogger.warn(`[AGENT] Skipping variant ${variant}: not supported by ${modelInfo.model}`);
192
+ return undefined;
193
+ }
194
+ if (context.isThread && context.sessionId) {
195
+ await setSessionModel({
196
+ sessionId: context.sessionId,
197
+ modelId: modelInfo.model,
198
+ variant: matchedVariant,
199
+ });
200
+ return matchedVariant;
201
+ }
202
+ const existing = await getChannelModel(context.channelId);
203
+ await setChannelModel({
204
+ channelId: context.channelId,
205
+ modelId: existing?.modelId ?? modelInfo.model,
206
+ variant: matchedVariant,
207
+ });
208
+ return matchedVariant;
209
+ }
164
210
  function formatAgentModelLine(modelInfo) {
165
211
  if (modelInfo.type === 'none')
166
212
  return '';
@@ -174,15 +220,16 @@ function formatAgentModelLine(modelInfo) {
174
220
  }
175
221
  return `${line}\nThis model comes from a ${modelInfo.type} override. Use /model and press Clear override if you want this agent's model.`;
176
222
  }
177
- function formatAgentPreferenceReply({ agentName, previousAgentName, modelInfo, scope, }) {
223
+ function formatAgentPreferenceReply({ agentName, previousAgentName, modelInfo, scope, variant, }) {
178
224
  const sameAgent = previousAgentName === agentName;
179
225
  const verb = sameAgent ? 'Using' : 'Switched to';
180
226
  const previousText = !sameAgent && previousAgentName ? ` (was **${previousAgentName}**)` : '';
181
227
  const modelText = formatAgentModelLine(modelInfo);
228
+ const variantText = variant ? `\nVariant: **${variant}**` : '';
182
229
  if (scope === 'session') {
183
- return `${verb} **${agentName}** agent for this session${previousText}${modelText}\nThe agent will change on the next message.`;
230
+ return `${verb} **${agentName}** agent for this session${previousText}${modelText}${variantText}\nThe agent will change on the next message.`;
184
231
  }
185
- return `${verb} **${agentName}** agent for this channel${previousText}${modelText}\nAll new sessions will use this agent.`;
232
+ return `${verb} **${agentName}** agent for this channel${previousText}${modelText}${variantText}\nAll new sessions will use this agent.`;
186
233
  }
187
234
  async function resolveAgentModelInfo({ context, agentName, }) {
188
235
  const getClient = await initializeOpencodeForDirectory(context.dir);
@@ -296,7 +343,13 @@ export async function handleAgentSelectMenu(interaction) {
296
343
  channelId: context.channelId,
297
344
  });
298
345
  const previousAgentName = previousAgent.type !== 'none' ? previousAgent.agent : undefined;
299
- await setAgentForContext({ context, agentName: selectedAgent });
346
+ await waitForCurrentThreadIngress();
347
+ try {
348
+ await setAgentForContext({ context, agentName: selectedAgent });
349
+ }
350
+ finally {
351
+ releaseCurrentThreadIngress();
352
+ }
300
353
  const modelInfo = await resolveAgentModelInfo({
301
354
  context,
302
355
  agentName: selectedAgent,
@@ -333,9 +386,16 @@ export async function handleAgentSelectMenu(interaction) {
333
386
  export async function handleQuickAgentCommand({ command, appId, }) {
334
387
  const fallbackAgentName = command.commandName.replace(/-agent$/, '');
335
388
  const prompt = command.options.getString('prompt') || undefined;
389
+ const variant = command.options.getString('variant') || undefined;
336
390
  // Prompt mode: send the prompt with this agent immediately.
337
391
  if (prompt) {
338
- return handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt });
392
+ return handleQuickAgentWithPrompt({
393
+ command,
394
+ appId,
395
+ fallbackAgentName,
396
+ prompt,
397
+ variant,
398
+ });
339
399
  }
340
400
  // No prompt: switch the persistent agent preference (original behavior).
341
401
  await command.deferReply();
@@ -356,20 +416,37 @@ export async function handleQuickAgentCommand({ command, appId, }) {
356
416
  channelId: context.channelId,
357
417
  });
358
418
  const previousAgentName = previousAgent.type !== 'none' ? previousAgent.agent : undefined;
359
- await setAgentForContext({ context, agentName: resolvedAgentName });
360
- const modelInfo = await resolveAgentModelInfo({
361
- context,
362
- agentName: resolvedAgentName,
363
- });
364
- const scope = context.isThread && context.sessionId ? 'session' : 'channel';
365
- await command.editReply({
366
- content: formatAgentPreferenceReply({
419
+ await waitForCurrentThreadIngress();
420
+ try {
421
+ await setAgentForContext({ context, agentName: resolvedAgentName });
422
+ const modelInfo = await resolveAgentModelInfo({
423
+ context,
367
424
  agentName: resolvedAgentName,
368
- previousAgentName,
369
- modelInfo,
370
- scope,
371
- }),
372
- });
425
+ });
426
+ const appliedVariant = variant
427
+ ? await applyQuickAgentVariant({
428
+ context,
429
+ agentName: resolvedAgentName,
430
+ variant,
431
+ })
432
+ : undefined;
433
+ const scope = context.isThread && context.sessionId ? 'session' : 'channel';
434
+ const unsupportedVariantNote = variant && !appliedVariant
435
+ ? `\nVariant **${variant}** is not supported by this agent's model.`
436
+ : '';
437
+ await command.editReply({
438
+ content: `${formatAgentPreferenceReply({
439
+ agentName: resolvedAgentName,
440
+ previousAgentName,
441
+ modelInfo,
442
+ scope,
443
+ variant: appliedVariant,
444
+ })}${unsupportedVariantNote}`,
445
+ });
446
+ }
447
+ finally {
448
+ releaseCurrentThreadIngress();
449
+ }
373
450
  }
374
451
  catch (error) {
375
452
  agentLogger.error('Error in quick agent command:', error);
@@ -384,7 +461,7 @@ export async function handleQuickAgentCommand({ command, appId, }) {
384
461
  * In a channel: create a new thread whose session starts with the requested agent.
385
462
  * Channel-level preferences are not changed.
386
463
  */
387
- async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt, }) {
464
+ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt, variant, }) {
388
465
  const channel = command.channel;
389
466
  if (!channel) {
390
467
  await command.reply({
@@ -422,7 +499,7 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
422
499
  });
423
500
  // Visible reply showing the one-shot prompt (not ephemeral, so it appears in thread).
424
501
  await command.reply({
425
- content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
502
+ content: `${QUEUE_PREFIX}**${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
426
503
  flags: SILENT_MESSAGE_FLAGS,
427
504
  });
428
505
  await runtime.enqueueIncoming({
@@ -432,6 +509,7 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
432
509
  agent: resolvedAgentName,
433
510
  appId,
434
511
  mode: 'opencode',
512
+ variant,
435
513
  });
436
514
  }
437
515
  else if (channel.type === ChannelType.GuildText) {
@@ -459,7 +537,7 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
459
537
  ? `${WORKTREE_PREFIX}${baseThreadName}`
460
538
  : baseThreadName;
461
539
  const starterMessage = await channel.send({
462
- content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
540
+ content: `${QUEUE_PREFIX}**${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
463
541
  flags: SILENT_MESSAGE_FLAGS,
464
542
  });
465
543
  const thread = await starterMessage.startThread({
@@ -487,14 +565,12 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
487
565
  rest: command.client.rest,
488
566
  });
489
567
  }
490
- const sessionDirectory = await (async () => {
491
- if (!worktreePromise)
492
- return projectDirectory;
493
- const result = await worktreePromise;
494
- if (result instanceof Error)
495
- return projectDirectory;
496
- return result;
497
- })();
568
+ const worktreeResult = worktreePromise ? await worktreePromise : projectDirectory;
569
+ if (worktreeResult instanceof Error) {
570
+ await command.editReply(`Worktree creation failed: ${worktreeResult.message}`);
571
+ return;
572
+ }
573
+ const sessionDirectory = worktreeResult;
498
574
  await command
499
575
  .editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`)
500
576
  .catch(() => {
@@ -515,6 +591,7 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
515
591
  agent: resolvedAgentName,
516
592
  appId,
517
593
  mode: 'opencode',
594
+ variant,
518
595
  });
519
596
  }
520
597
  else {
@@ -524,3 +601,58 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
524
601
  });
525
602
  }
526
603
  }
604
+ export async function handleQuickAgentAutocomplete({ interaction, }) {
605
+ if (interaction.options.getFocused(true).name !== 'variant') {
606
+ await interaction.respond([]);
607
+ return;
608
+ }
609
+ const focusedValue = interaction.options.getFocused().trim().toLowerCase();
610
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction);
611
+ if (!projectDirectory) {
612
+ await interaction.respond([]);
613
+ return;
614
+ }
615
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
616
+ if (getClient instanceof Error) {
617
+ await interaction.respond([]);
618
+ return;
619
+ }
620
+ const [providersResponse, agentsResponse] = await Promise.all([
621
+ getClient()
622
+ .provider.list({ directory: projectDirectory })
623
+ .catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e })),
624
+ getClient()
625
+ .app.agents({ directory: projectDirectory })
626
+ .catch((e) => new OpenCodeSdkError({ operation: 'app.agents', cause: e })),
627
+ ]);
628
+ if (providersResponse instanceof Error || agentsResponse instanceof Error) {
629
+ agentLogger.warn('[AUTOCOMPLETE] Failed to fetch variant choices');
630
+ await interaction.respond([]);
631
+ return;
632
+ }
633
+ if (!providersResponse.data || !agentsResponse.data) {
634
+ await interaction.respond([]);
635
+ return;
636
+ }
637
+ const resolvedAgentName = parseQuickAgentNameFromDescription(interaction.command?.description) ||
638
+ interaction.commandName.replace(/-agent$/, '');
639
+ const agent = agentsResponse.data.find((candidate) => candidate.name === resolvedAgentName);
640
+ const agentModel = agent?.model;
641
+ if (!agentModel) {
642
+ await interaction.respond([]);
643
+ return;
644
+ }
645
+ const variants = getThinkingValuesForModel({
646
+ providers: providersResponse.data.all,
647
+ providerId: agentModel.providerID,
648
+ modelId: agentModel.modelID,
649
+ }).filter((variant) => {
650
+ if (!focusedValue)
651
+ return true;
652
+ return variant.toLowerCase().includes(focusedValue);
653
+ });
654
+ await interaction.respond(variants.slice(0, 25).map((variant) => ({
655
+ name: variant.slice(0, 100),
656
+ value: variant,
657
+ })));
658
+ }
@@ -5,11 +5,14 @@ import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder,
5
5
  import crypto from 'node:crypto';
6
6
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
7
7
  import { getOpencodeClient } from '../opencode.js';
8
+ import { DiscordOperationError } from '../errors.js';
8
9
  import { createLogger, LogPrefix } from '../logger.js';
10
+ import { QUEUE_PREFIX } from '../message-formatting.js';
9
11
  const logger = createLogger(LogPrefix.ASK_QUESTION);
10
12
  // Store pending question contexts by hash.
11
- // TTL prevents unbounded growth if user never answers a question.
12
- const QUESTION_CONTEXT_TTL_MS = 10 * 60 * 1000;
13
+ // No TTL on purpose: a question can only be answered by the user, so expiring it
14
+ // could only abort the run. Cleanup happens on answer, on a new user message, on
15
+ // abort (abortActiveRunInternal), and on thread dispose.
13
16
  export const pendingQuestionContexts = new Map();
14
17
  export function areAllQuestionsAnswered({ totalQuestions, answers, }) {
15
18
  for (let i = 0; i < totalQuestions; i++) {
@@ -75,32 +78,6 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
75
78
  contextHash,
76
79
  };
77
80
  pendingQuestionContexts.set(contextHash, context);
78
- // On TTL expiry: hide the dropdown UI and abort the session so OpenCode
79
- // unblocks. We intentionally do NOT call question.reply() — sending 'Other'
80
- // made the model think the user chose an option when they didn't.
81
- setTimeout(async () => {
82
- const ctx = pendingQuestionContexts.get(contextHash);
83
- if (!ctx) {
84
- return;
85
- }
86
- // Delete context first so the dropdown becomes inert immediately.
87
- // Without this, a user clicking during the abort() await would still
88
- // be accepted by handleAskQuestionSelectMenu, then abort() would
89
- // kill that valid run.
90
- deletePendingQuestionContextsForRequest({
91
- threadId: ctx.thread.id,
92
- requestId: ctx.requestId,
93
- });
94
- // Abort the session so OpenCode isn't stuck waiting for a reply
95
- const client = getOpencodeClient(ctx.directory);
96
- if (client) {
97
- await client.session.abort({
98
- sessionID: ctx.sessionId,
99
- }).catch((error) => {
100
- logger.error('Failed to abort session after question expiry:', error);
101
- });
102
- }
103
- }, QUESTION_CONTEXT_TTL_MS).unref();
104
81
  // Send one message per question with its dropdown directly underneath
105
82
  for (let i = 0; i < input.questions.length; i++) {
106
83
  const q = input.questions[i];
@@ -129,11 +106,24 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
129
106
  selectMenu.setMaxValues(options.length);
130
107
  }
131
108
  const actionRow = new ActionRowBuilder().addComponents(selectMenu);
132
- await thread.send({
109
+ const sendResult = await thread.send({
133
110
  content: `**${(q.header || '').slice(0, 200)}**\n${q.question.slice(0, 1700)}`,
134
111
  components: [actionRow],
135
112
  flags: silent ? SILENT_MESSAGE_FLAGS : NOTIFY_MESSAGE_FLAGS,
136
- });
113
+ }).catch((e) => new DiscordOperationError({ operation: 'sendQuestionDropdown', cause: e }));
114
+ // Without a visible dropdown the user can never answer, and there is no TTL
115
+ // to rescue the run, so drop the context and abort so OpenCode unblocks.
116
+ if (sendResult instanceof Error) {
117
+ logger.error('Failed to send question dropdown:', sendResult);
118
+ deletePendingQuestionContextsForRequest({ threadId: thread.id, requestId });
119
+ const client = getOpencodeClient(directory);
120
+ if (client) {
121
+ await client.session.abort({ sessionID: sessionId }).catch((error) => {
122
+ logger.error('Failed to abort session after question send failure:', error);
123
+ });
124
+ }
125
+ return;
126
+ }
137
127
  }
138
128
  logger.log(`Showed ${input.questions.length} question dropdown(s) for session ${sessionId}`);
139
129
  }
@@ -194,7 +184,7 @@ export async function handleAskQuestionSelectMenu(interaction) {
194
184
  components: [], // Remove the dropdown
195
185
  });
196
186
  const username = interaction.user.globalName || interaction.user.username;
197
- await sendThreadMessage(context.thread, `» **${username}:** ${answeredText}`);
187
+ await sendThreadMessage(context.thread, `${QUEUE_PREFIX}**${username}:** ${answeredText}`);
198
188
  // Check if all questions are answered
199
189
  if (areAllQuestionsAnswered(context)) {
200
190
  // All questions answered - send result back to session
@@ -276,7 +266,7 @@ export function parseAskUserQuestionTool(part) {
276
266
  * the tool answer so the model sees the user's response. The caller should
277
267
  * NOT also enqueue the message as a new prompt.
278
268
  * Returns 'replied' on success, 'reply-failed' if the reply call fails
279
- * (context kept pending so TTL can retry).
269
+ * (context kept pending so the user can retry).
280
270
  */
281
271
  export async function cancelPendingQuestion(threadId, userMessage) {
282
272
  // Find pending question for this thread
@@ -320,7 +310,7 @@ export async function cancelPendingQuestion(threadId, userMessage) {
320
310
  }
321
311
  catch (error) {
322
312
  logger.error('Failed to answer question:', error);
323
- // Keep context pending so TTL can still fire.
313
+ // Keep context pending so the user can retry.
324
314
  // Caller should not consume the user message since reply failed.
325
315
  return 'reply-failed';
326
316
  }
@@ -1,8 +1,11 @@
1
1
  // Tests AskUserQuestion request deduplication and cleanup helpers.
2
2
  import { afterEach, describe, expect, test, vi } from 'vitest';
3
3
  import { areAllQuestionsAnswered, deletePendingQuestionContextsForRequest, pendingQuestionContexts, showAskUserQuestionDropdowns, } from './ask-question.js';
4
- function createFakeThread() {
4
+ function createFakeThread({ failSend } = {}) {
5
5
  const send = vi.fn(async () => {
6
+ if (failSend) {
7
+ throw new Error('Missing Permissions');
8
+ }
6
9
  return { id: 'msg-1' };
7
10
  });
8
11
  return {
@@ -88,6 +91,45 @@ describe('ask-question', () => {
88
91
  expect(removed).toBe(2);
89
92
  expect([...pendingQuestionContexts.keys()]).toEqual(['ctx-3']);
90
93
  });
94
+ test('does not schedule a question expiry timer', async () => {
95
+ const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
96
+ await showAskUserQuestionDropdowns({
97
+ thread: createFakeThread(),
98
+ sessionId: 'ses-1',
99
+ directory: '/project',
100
+ requestId: 'req-no-ttl',
101
+ input: {
102
+ questions: [{
103
+ question: 'Choose one',
104
+ header: 'Pick',
105
+ options: [
106
+ { label: 'Alpha', description: 'A' },
107
+ { label: 'Beta', description: 'B' },
108
+ ],
109
+ }],
110
+ },
111
+ });
112
+ expect(timeoutSpy).not.toHaveBeenCalled();
113
+ });
114
+ test('drops the context when the dropdown message fails to send', async () => {
115
+ await showAskUserQuestionDropdowns({
116
+ thread: createFakeThread({ failSend: true }),
117
+ sessionId: 'ses-1',
118
+ directory: '/project',
119
+ requestId: 'req-send-fails',
120
+ input: {
121
+ questions: [{
122
+ question: 'Choose one',
123
+ header: 'Pick',
124
+ options: [
125
+ { label: 'Alpha', description: 'A' },
126
+ { label: 'Beta', description: 'B' },
127
+ ],
128
+ }],
129
+ },
130
+ });
131
+ expect(pendingQuestionContexts.size).toBe(0);
132
+ });
91
133
  test('requires every question to have an answer', () => {
92
134
  expect(areAllQuestionsAnswered({
93
135
  totalQuestions: 3,
@@ -3,14 +3,14 @@
3
3
  // a new thread, forks the entire session (no messageID), and immediately
4
4
  // dispatches the user's prompt so the forked session starts working right away.
5
5
  import { ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
6
- import { getThreadSession, setThreadSession } from '../database.js';
6
+ import { getThreadSession, setThreadSession, getThreadWorktreeOrWorkspace, createPendingWorkspace, setWorkspaceReady, } from '../database.js';
7
7
  import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
8
8
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
9
9
  import { createLogger, LogPrefix } from '../logger.js';
10
10
  import { initializeOpencodeForDirectory } from '../opencode.js';
11
11
  import { copyCurrentSessionModel } from './model.js';
12
12
  const logger = createLogger(LogPrefix.FORK);
13
- export async function forkSessionToBtwThread({ sourceThread, projectDirectory, sdkDirectory, prompt, userId, username, appId, }) {
13
+ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, sdkDirectory, prompt, modelPrompt = prompt, userId, username, appId, agent, images, }) {
14
14
  // Parallelize: session lookup + opencode init + parent channel resolve are independent
15
15
  const [sessionId, getClientResult, textChannel] = await Promise.all([
16
16
  getThreadSession(sourceThread.id),
@@ -29,7 +29,7 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, s
29
29
  return new Error('Could not resolve parent text channel');
30
30
  }
31
31
  // Fork must succeed before creating the Discord thread to avoid orphan threads
32
- const forkResponse = await getClientResult().session.fork({ sessionID: sessionId });
32
+ const forkResponse = await getClientResult().session.fork({ sessionID: sessionId, directory: sdkDirectory });
33
33
  if (!forkResponse.data) {
34
34
  return new Error('Failed to fork session');
35
35
  }
@@ -41,7 +41,7 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, s
41
41
  channelId,
42
42
  appId,
43
43
  getClient: getClientResult,
44
- directory: projectDirectory,
44
+ directory: sdkDirectory,
45
45
  });
46
46
  const thread = await textChannel.threads.create({
47
47
  name: `btw: ${prompt}`.slice(0, 100),
@@ -50,10 +50,26 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, s
50
50
  });
51
51
  // DB mapping must complete before user-visible actions so the thread is routable
52
52
  await setThreadSession(thread.id, forkedSession.id);
53
+ const sourceWorkspace = await getThreadWorktreeOrWorkspace(sourceThread.id);
54
+ if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
55
+ await createPendingWorkspace({
56
+ threadId: thread.id,
57
+ workspaceType: sourceWorkspace.workspace_type,
58
+ workspaceName: sourceWorkspace.workspace_name ?? '',
59
+ projectDirectory,
60
+ });
61
+ await setWorkspaceReady({
62
+ threadId: thread.id,
63
+ workspaceId: sourceWorkspace.workspace_id ?? undefined,
64
+ workspaceDirectory: sourceWorkspace.workspace_directory,
65
+ });
66
+ }
53
67
  // Parallelize: member add and status message are independent best-effort actions
54
68
  const sourceThreadLink = `<#${sourceThread.id}>`;
55
69
  await Promise.all([
56
- thread.members.add(userId).catch(() => { }),
70
+ thread.members.add(userId).catch((error) => {
71
+ logger.warn('Could not add fork member:', error);
72
+ }),
57
73
  sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`),
58
74
  ]);
59
75
  logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from source thread ${sourceThread.id} (session ${sessionId})`);
@@ -68,7 +84,7 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, s
68
84
  `Parent session: ${sessionId} (thread <#${sourceThread.id}>)`,
69
85
  `Do NOT send messages to the parent session unless the user explicitly asks you to.`,
70
86
  ``,
71
- prompt,
87
+ modelPrompt,
72
88
  ].join('\n');
73
89
  const runtime = getOrCreateRuntime({
74
90
  threadId: thread.id,
@@ -77,13 +93,19 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, s
77
93
  sdkDirectory,
78
94
  channelId,
79
95
  appId,
96
+ sessionId: forkedSession.id,
80
97
  });
81
98
  await runtime.enqueueIncoming({
82
99
  prompt: wrappedPrompt,
100
+ agent,
101
+ images,
83
102
  userId,
84
103
  username,
85
104
  appId,
86
105
  mode: 'opencode',
106
+ }).catch(async (error) => {
107
+ logger.error('Fork dispatch failed:', error);
108
+ await sendThreadMessage(thread, 'Could not send the request to OpenCode. Send your request again in this thread.');
87
109
  });
88
110
  return {
89
111
  thread,
@@ -12,6 +12,7 @@ import path from 'node:path';
12
12
  import { createLogger, LogPrefix } from '../logger.js';
13
13
  import { notifyError } from '../sentry.js';
14
14
  import { NOTIFY_MESSAGE_FLAGS, sendThreadMessage } from '../discord-utils.js';
15
+ import { QUEUE_PREFIX } from '../message-formatting.js';
15
16
  const logger = createLogger(LogPrefix.FILE_UPLOAD);
16
17
  // 5 minute TTL for pending contexts - if user doesn't click within this time,
17
18
  // clean up the context and resolve with empty array to unblock the plugin tool
@@ -221,7 +222,7 @@ export async function handleFileUploadModalSubmit(interaction) {
221
222
  await interaction.editReply({ content: summary });
222
223
  if (downloadedPaths.length > 0) {
223
224
  const username = interaction.user.globalName || interaction.user.username;
224
- await sendThreadMessage(context.thread, `» **${username}:** Uploaded ${fileNames.join(', ')}`);
225
+ await sendThreadMessage(context.thread, `${QUEUE_PREFIX}**${username}:** Uploaded ${fileNames.join(', ')}`);
225
226
  }
226
227
  resolveContext(context, downloadedPaths);
227
228
  logger.log(`File upload completed for session ${context.sessionId}: ${downloadedPaths.length} files`);
@@ -1,7 +1,7 @@
1
1
  // /fork-subagent command - Fork a subagent task session into a new thread.
2
2
  import { ActionRowBuilder, MessageFlags, StringSelectMenuBuilder, ThreadAutoArchiveDuration, } from 'discord.js';
3
3
  import { getSessionEventSnapshot, getThreadSession, setThreadSession, } from '../database.js';
4
- import { resolveTextChannel, resolveWorkingDirectory, sendThreadMessage, } from '../discord-utils.js';
4
+ import { resolveTextChannel, resolveWorkingDirectory, sendThreadMessage, sendSessionPartBatches, } from '../discord-utils.js';
5
5
  import { collectSessionChunks, batchChunksForDiscord, } from '../message-formatting.js';
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
7
7
  import { getDerivedSubagentSessions, } from '../session-handler/event-stream-state.js';
@@ -163,9 +163,7 @@ export async function handleForkSubagentSelectMenu(interaction) {
163
163
  limit: 30,
164
164
  });
165
165
  const batched = batchChunksForDiscord(chunks);
166
- for (const batch of batched) {
167
- await sendThreadMessage(forkedThread, batch.content);
168
- }
166
+ await sendSessionPartBatches({ thread: forkedThread, batches: batched });
169
167
  }
170
168
  }
171
169
  catch (error) {
@@ -2,7 +2,7 @@
2
2
  import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
3
3
  import { getThreadSession, setThreadSession, setPartMessagesBatch, } from '../database.js';
4
4
  import { initializeOpencodeForDirectory } from '../opencode.js';
5
- import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
5
+ import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, sendSessionPartBatches, } from '../discord-utils.js';
6
6
  import { collectSessionChunks, batchChunksForDiscord, } from '../message-formatting.js';
7
7
  import { createLogger, LogPrefix } from '../logger.js';
8
8
  import * as errore from 'errore';
@@ -240,11 +240,11 @@ export async function handleForkSelectMenu(interaction) {
240
240
  limit: 30,
241
241
  });
242
242
  const batched = batchChunksForDiscord(chunks);
243
- for (const batch of batched) {
244
- const discordMessage = await sendThreadMessage(thread, batch.content);
243
+ const sent = await sendSessionPartBatches({ thread, batches: batched });
244
+ for (const batch of sent) {
245
245
  await setPartMessagesBatch(batch.partIds.map((partId) => ({
246
246
  partId,
247
- messageId: discordMessage.id,
247
+ messageId: batch.message.id,
248
248
  threadId: thread.id,
249
249
  })));
250
250
  }
@@ -104,16 +104,23 @@ export async function handleLastSessionsCommand({ command, }) {
104
104
  }
105
105
  const tableMarkdown = buildSessionTable({ rows });
106
106
  const segments = splitTablesFromMarkdown(tableMarkdown);
107
- const allComponents = segments.flatMap((segment) => {
108
- if (segment.type === 'components') {
109
- return segment.components;
110
- }
111
- const textDisplay = {
112
- type: ComponentType.TextDisplay,
113
- content: segment.text,
114
- };
115
- return [textDisplay];
116
- });
107
+ const btwNote = {
108
+ type: ComponentType.TextDisplay,
109
+ content: 'Titles prefixed `btw:` are side sessions that answer a related question. They are not duplicate sessions.',
110
+ };
111
+ const allComponents = [
112
+ btwNote,
113
+ ...segments.flatMap((segment) => {
114
+ if (segment.type === 'components') {
115
+ return segment.components;
116
+ }
117
+ const textDisplay = {
118
+ type: ComponentType.TextDisplay,
119
+ content: segment.text,
120
+ };
121
+ return [textDisplay];
122
+ }),
123
+ ];
117
124
  const { components } = truncateComponents(allComponents);
118
125
  await command.editReply({
119
126
  components,