kimaki 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/dist/agent-model.e2e.test.js +247 -64
  2. package/dist/anthropic-account-identity.js +8 -1
  3. package/dist/anthropic-auth-plugin.js +9 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/bash-tool-schema-plugin.js +32 -0
  6. package/dist/bash-tool-schema-plugin.test.js +49 -0
  7. package/dist/cli-commands/project.js +11 -27
  8. package/dist/cli-commands/send.js +6 -1
  9. package/dist/cli-commands/session.js +139 -82
  10. package/dist/cli-commands/task.js +21 -14
  11. package/dist/cli-parsing.test.js +24 -1
  12. package/dist/cli-runner.js +16 -4
  13. package/dist/cli-send-thread.e2e.test.js +9 -9
  14. package/dist/commands/action-buttons.js +2 -1
  15. package/dist/commands/add-project.js +4 -7
  16. package/dist/commands/agent.js +158 -24
  17. package/dist/commands/ask-question.js +2 -1
  18. package/dist/commands/btw.js +28 -6
  19. package/dist/commands/file-upload.js +2 -1
  20. package/dist/commands/fork-subagent.js +2 -4
  21. package/dist/commands/fork.js +4 -4
  22. package/dist/commands/last-sessions.js +17 -10
  23. package/dist/commands/merge-worktree.js +13 -6
  24. package/dist/commands/model-variant.js +3 -2
  25. package/dist/commands/model.js +4 -3
  26. package/dist/commands/queue.js +3 -2
  27. package/dist/commands/resume.js +4 -4
  28. package/dist/commands/session.js +78 -63
  29. package/dist/cpu-profiler.js +211 -0
  30. package/dist/cpu-profiler.test.js +160 -0
  31. package/dist/database.js +28 -0
  32. package/dist/db.js +11 -0
  33. package/dist/db.test.js +100 -0
  34. package/dist/default-channel-provisioning.test.js +148 -0
  35. package/dist/discord-bot.js +501 -474
  36. package/dist/discord-command-registration.js +16 -9
  37. package/dist/discord-command-registration.test.js +17 -0
  38. package/dist/discord-utils.js +84 -0
  39. package/dist/discord-utils.test.js +135 -1
  40. package/dist/errors.js +5 -0
  41. package/dist/external-directory-permissions.e2e.test.js +11 -7
  42. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  43. package/dist/external-opencode-sync.js +94 -17
  44. package/dist/external-opencode-sync.test.js +206 -0
  45. package/dist/extract-sdk-error-message.test.js +13 -0
  46. package/dist/gateway-proxy.e2e.test.js +18 -18
  47. package/dist/interaction-handler.js +416 -398
  48. package/dist/ipc-tools-plugin.js +12 -7
  49. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +2 -2
  50. package/dist/kimaki-opencode-plugin.js +2 -0
  51. package/dist/logger.js +1 -0
  52. package/dist/message-formatting.js +163 -44
  53. package/dist/message-formatting.test.js +473 -13
  54. package/dist/message-preprocessing.js +105 -1
  55. package/dist/opencode-command.js +28 -0
  56. package/dist/opencode-command.test.js +49 -1
  57. package/dist/opencode-interrupt-plugin.js +4 -0
  58. package/dist/opencode-interrupt-plugin.test.js +24 -0
  59. package/dist/opencode.js +29 -3
  60. package/dist/project-add.test.js +62 -0
  61. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  62. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  63. package/dist/question-text-before-dropdown.e2e.test.js +5 -5
  64. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  65. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  66. package/dist/queue-advanced-footer.e2e.test.js +29 -32
  67. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  68. package/dist/queue-advanced-permissions-typing.e2e.test.js +9 -7
  69. package/dist/queue-advanced-question.e2e.test.js +2 -2
  70. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  71. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  72. package/dist/queue-delete-message.e2e.test.js +3 -3
  73. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  74. package/dist/queue-interrupt-drain.e2e.test.js +20 -20
  75. package/dist/queue-question-select-drain.e2e.test.js +7 -7
  76. package/dist/runtime-lifecycle.e2e.test.js +16 -19
  77. package/dist/schema.js +1 -1
  78. package/dist/session-handler/event-stream-state.js +124 -0
  79. package/dist/session-handler/event-stream-state.test.js +322 -1
  80. package/dist/session-handler/model-utils.js +16 -3
  81. package/dist/session-handler/model-utils.test.js +103 -3
  82. package/dist/session-handler/opencode-session-event-log.js +30 -1
  83. package/dist/session-handler/thread-ingress.test.js +41 -0
  84. package/dist/session-handler/thread-runtime-state.js +2 -0
  85. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  86. package/dist/session-handler/thread-session-runtime.js +340 -135
  87. package/dist/session-search.js +123 -4
  88. package/dist/session-search.test.js +248 -1
  89. package/dist/session-title-rename.test.js +30 -0
  90. package/dist/sleep.e2e.test.js +14 -11
  91. package/dist/system-message.js +45 -12
  92. package/dist/system-message.test.js +117 -10
  93. package/dist/task-runner.js +2 -1
  94. package/dist/task-schedule.js +32 -2
  95. package/dist/task-schedule.test.js +79 -1
  96. package/dist/test-utils.js +17 -9
  97. package/dist/thinking-utils.js +10 -0
  98. package/dist/thinking-utils.test.js +35 -0
  99. package/dist/thread-message-queue.e2e.test.js +65 -54
  100. package/dist/undo-redo.e2e.test.js +17 -9
  101. package/dist/voice-handler.js +21 -4
  102. package/dist/voice-message.e2e.test.js +144 -30
  103. package/dist/voice-routing-input.e2e.test.js +195 -0
  104. package/dist/voice-routing-question.e2e.test.js +125 -0
  105. package/dist/voice.js +28 -8
  106. package/dist/voice.test.js +41 -1
  107. package/dist/worktree-lifecycle.e2e.test.js +30 -30
  108. package/package.json +8 -8
  109. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  110. package/skills/critique/SKILL.md +4 -4
  111. package/skills/errore/SKILL.md +56 -232
  112. package/skills/holocron/SKILL.md +117 -17
  113. package/src/agent-model.e2e.test.ts +293 -66
  114. package/src/anthropic-account-identity.ts +19 -1
  115. package/src/anthropic-auth-plugin.test.ts +19 -0
  116. package/src/anthropic-auth-plugin.ts +11 -16
  117. package/src/bash-tool-schema-plugin.test.ts +55 -0
  118. package/src/bash-tool-schema-plugin.ts +46 -0
  119. package/src/cli-commands/project.ts +14 -35
  120. package/src/cli-commands/send.ts +6 -1
  121. package/src/cli-commands/session.ts +171 -105
  122. package/src/cli-commands/task.ts +22 -15
  123. package/src/cli-parsing.test.ts +34 -1
  124. package/src/cli-runner.ts +16 -3
  125. package/src/cli-send-thread.e2e.test.ts +9 -11
  126. package/src/commands/action-buttons.ts +2 -1
  127. package/src/commands/add-project.ts +4 -8
  128. package/src/commands/agent.ts +199 -23
  129. package/src/commands/ask-question.ts +2 -1
  130. package/src/commands/btw.ts +40 -5
  131. package/src/commands/file-upload.ts +2 -1
  132. package/src/commands/fork-subagent.ts +2 -3
  133. package/src/commands/fork.ts +4 -3
  134. package/src/commands/last-sessions.ts +10 -4
  135. package/src/commands/merge-worktree.ts +13 -6
  136. package/src/commands/model-variant.ts +3 -2
  137. package/src/commands/model.ts +4 -3
  138. package/src/commands/queue.ts +3 -2
  139. package/src/commands/resume.ts +4 -3
  140. package/src/commands/session.ts +94 -70
  141. package/src/cpu-profiler.test.ts +183 -0
  142. package/src/cpu-profiler.ts +293 -0
  143. package/src/database.ts +40 -0
  144. package/src/db.test.ts +105 -0
  145. package/src/db.ts +12 -0
  146. package/src/default-channel-provisioning.test.ts +155 -0
  147. package/src/discord-bot.ts +43 -7
  148. package/src/discord-command-registration.test.ts +20 -0
  149. package/src/discord-command-registration.ts +27 -11
  150. package/src/discord-utils.test.ts +157 -0
  151. package/src/discord-utils.ts +131 -1
  152. package/src/errors.ts +7 -0
  153. package/src/external-directory-permissions.e2e.test.ts +11 -7
  154. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  155. package/src/external-opencode-sync.test.ts +248 -0
  156. package/src/external-opencode-sync.ts +122 -19
  157. package/src/extract-sdk-error-message.test.ts +18 -0
  158. package/src/gateway-proxy.e2e.test.ts +18 -17
  159. package/src/interaction-handler.ts +36 -13
  160. package/src/ipc-tools-plugin.ts +16 -9
  161. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +5 -5
  162. package/src/kimaki-opencode-plugin.ts +2 -0
  163. package/src/logger.ts +1 -0
  164. package/src/message-formatting.test.ts +586 -13
  165. package/src/message-formatting.ts +223 -46
  166. package/src/message-preprocessing.ts +116 -1
  167. package/src/opencode-command.test.ts +59 -0
  168. package/src/opencode-command.ts +38 -0
  169. package/src/opencode-interrupt-plugin.test.ts +32 -0
  170. package/src/opencode-interrupt-plugin.ts +3 -0
  171. package/src/opencode.ts +44 -4
  172. package/src/project-add.test.ts +80 -0
  173. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  174. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  175. package/src/question-text-before-dropdown.e2e.test.ts +5 -5
  176. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  177. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  178. package/src/queue-advanced-footer.e2e.test.ts +30 -31
  179. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  180. package/src/queue-advanced-permissions-typing.e2e.test.ts +9 -7
  181. package/src/queue-advanced-question.e2e.test.ts +2 -2
  182. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  183. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  184. package/src/queue-delete-message.e2e.test.ts +3 -3
  185. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  186. package/src/queue-interrupt-drain.e2e.test.ts +20 -20
  187. package/src/queue-question-select-drain.e2e.test.ts +7 -7
  188. package/src/runtime-lifecycle.e2e.test.ts +16 -18
  189. package/src/schema.sql +1 -1
  190. package/src/schema.ts +1 -1
  191. package/src/session-handler/event-stream-state.test.ts +353 -0
  192. package/src/session-handler/event-stream-state.ts +179 -0
  193. package/src/session-handler/model-utils.test.ts +125 -3
  194. package/src/session-handler/model-utils.ts +36 -4
  195. package/src/session-handler/opencode-session-event-log.ts +31 -1
  196. package/src/session-handler/thread-ingress.test.ts +54 -0
  197. package/src/session-handler/thread-runtime-state.ts +3 -0
  198. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  199. package/src/session-handler/thread-session-runtime.ts +427 -158
  200. package/src/session-search.test.ts +281 -0
  201. package/src/session-search.ts +241 -7
  202. package/src/session-title-rename.test.ts +45 -0
  203. package/src/sleep.e2e.test.ts +14 -11
  204. package/src/store.ts +1 -0
  205. package/src/system-message.test.ts +141 -10
  206. package/src/system-message.ts +66 -11
  207. package/src/task-runner.ts +2 -1
  208. package/src/task-schedule.test.ts +94 -0
  209. package/src/task-schedule.ts +47 -2
  210. package/src/test-utils.ts +21 -10
  211. package/src/thinking-utils.test.ts +46 -0
  212. package/src/thinking-utils.ts +21 -0
  213. package/src/thread-message-queue.e2e.test.ts +65 -53
  214. package/src/undo-redo.e2e.test.ts +17 -9
  215. package/src/voice-handler.ts +23 -4
  216. package/src/voice-message.e2e.test.ts +149 -29
  217. package/src/voice-routing-input.e2e.test.ts +203 -0
  218. package/src/voice-routing-question.e2e.test.ts +131 -0
  219. package/src/voice.test.ts +44 -0
  220. package/src/voice.ts +34 -6
  221. package/src/worktree-lifecycle.e2e.test.ts +30 -30
@@ -319,9 +319,9 @@ describe('kimaki send --channel thread creation', () => {
319
319
  "--- from: user (cli-send-tester)
320
320
  thread empty content seed
321
321
  --- from: assistant (TestBot)
322
- *using deterministic-provider/deterministic-v2*
323
- ⬥ caught-by-model
324
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000830>
322
+ > *using deterministic-provider/deterministic-v2*
323
+ > caught-by-model
324
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000830>
325
325
  --- from: user (cli-send-tester)
326
326
  --- from: assistant (TestBot)
327
327
  I can see you sent a message, but Discord did not include its text.
@@ -436,13 +436,13 @@ describe('kimaki send --channel thread creation', () => {
436
436
  }));
437
437
  // Add test user to thread
438
438
  await botClient.rest.put(Routes.threadMembers(threadData.id, TEST_USER_ID));
439
- // Wait for the bot to reply with the ⬥ prefix (proves ThreadCreate
440
- // handler picked up the starter message and started a session)
439
+ // Wait for the bot to reply (proves ThreadCreate handler picked up
440
+ // the starter message and started a session)
441
441
  await waitForBotMessageContaining({
442
442
  discord,
443
443
  threadId: threadData.id,
444
444
  userId: discord.botUserId,
445
- text: '⬥',
445
+ text: 'ok',
446
446
  timeout: 4_000,
447
447
  });
448
448
  // Wait for footer message (proves session completed successfully)
@@ -450,7 +450,7 @@ describe('kimaki send --channel thread creation', () => {
450
450
  discord,
451
451
  threadId: threadData.id,
452
452
  timeout: 4_000,
453
- afterMessageIncludes: '⬥',
453
+ afterMessageIncludes: 'ok',
454
454
  afterAuthorId: discord.botUserId,
455
455
  });
456
456
  // Verify no DiscordAPIError[160004] or other errors in the thread.
@@ -461,9 +461,9 @@ describe('kimaki send --channel thread creation', () => {
461
461
  return m.content.includes('Error:') || m.content.includes('160004');
462
462
  });
463
463
  expect(errorMessages).toHaveLength(0);
464
- // Verify at least one ⬥ reply exists (session produced output)
464
+ // Verify at least one reply exists (session produced output)
465
465
  const botReplies = messages.filter((m) => {
466
- return (m.author.id === discord.botUserId && m.content.startsWith('⬥'));
466
+ return m.author.id === discord.botUserId;
467
467
  });
468
468
  expect(botReplies.length).toBeGreaterThanOrEqual(1);
469
469
  }, 15_000);
@@ -6,6 +6,7 @@ import crypto from 'node:crypto';
6
6
  import { getThreadSession } from '../database.js';
7
7
  import { NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS, resolveWorkingDirectory, sendThreadMessage, } from '../discord-utils.js';
8
8
  import { createLogger } from '../logger.js';
9
+ import { QUEUE_PREFIX } from '../message-formatting.js';
9
10
  import { notifyError } from '../sentry.js';
10
11
  import { getOrCreateRuntime, } from '../session-handler/thread-session-runtime.js';
11
12
  const logger = createLogger('ACT_BTN');
@@ -214,7 +215,7 @@ export async function handleActionButton(interaction) {
214
215
  });
215
216
  const username = interaction.user.globalName || interaction.user.username;
216
217
  const prompt = `User clicked: ${button.label}`;
217
- await sendThreadMessage(thread, `» **${username}:** ${button.label}`);
218
+ await sendThreadMessage(thread, `${QUEUE_PREFIX}**${username}:** ${button.label}`);
218
219
  try {
219
220
  await sendClickedActionToModel({
220
221
  interaction,
@@ -1,7 +1,7 @@
1
1
  // /add-project command - Create Discord channels for an existing OpenCode project.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { findChannelsByDirectory, getAllTextChannelDirectories, } from '../database.js';
4
+ import { findRegisteredTextChannelForDirectory, getAllTextChannelDirectories, } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
6
  import { createProjectChannels } from '../channel-management.js';
7
7
  import { createLogger, LogPrefix } from '../logger.js';
@@ -38,12 +38,9 @@ export async function handleAddProjectCommand({ command, }) {
38
38
  await command.editReply(`Directory does not exist: ${directory}`);
39
39
  return;
40
40
  }
41
- const existingChannels = await findChannelsByDirectory({
42
- directory,
43
- channelType: 'text',
44
- });
45
- if (existingChannels.length > 0) {
46
- await command.editReply(`A channel already exists for this directory: <#${existingChannels[0].channel_id}>`);
41
+ const existingChannel = await findRegisteredTextChannelForDirectory(directory);
42
+ if (existingChannel) {
43
+ await command.editReply(`A channel already exists for this directory: <#${existingChannel.channel_id}>`);
47
44
  return;
48
45
  }
49
46
  const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
@@ -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({
@@ -513,6 +591,7 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
513
591
  agent: resolvedAgentName,
514
592
  appId,
515
593
  mode: 'opencode',
594
+ variant,
516
595
  });
517
596
  }
518
597
  else {
@@ -522,3 +601,58 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
522
601
  });
523
602
  }
524
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
+ }
@@ -7,6 +7,7 @@ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from '.
7
7
  import { getOpencodeClient } from '../opencode.js';
8
8
  import { DiscordOperationError } from '../errors.js';
9
9
  import { createLogger, LogPrefix } from '../logger.js';
10
+ import { QUEUE_PREFIX } from '../message-formatting.js';
10
11
  const logger = createLogger(LogPrefix.ASK_QUESTION);
11
12
  // Store pending question contexts by hash.
12
13
  // No TTL on purpose: a question can only be answered by the user, so expiring it
@@ -183,7 +184,7 @@ export async function handleAskQuestionSelectMenu(interaction) {
183
184
  components: [], // Remove the dropdown
184
185
  });
185
186
  const username = interaction.user.globalName || interaction.user.username;
186
- await sendThreadMessage(context.thread, `» **${username}:** ${answeredText}`);
187
+ await sendThreadMessage(context.thread, `${QUEUE_PREFIX}**${username}:** ${answeredText}`);
187
188
  // Check if all questions are answered
188
189
  if (areAllQuestionsAnswered(context)) {
189
190
  // All questions answered - send result back to session
@@ -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,
@@ -8,17 +8,23 @@ import { mergeWorktree, listBranchesByLastCommit, validateBranchRef, formatMerge
8
8
  import { sendThreadMessage, resolveWorkingDirectory, resolveProjectDirectoryFromAutocomplete, } from '../discord-utils.js';
9
9
  import { getOrCreateRuntime, } from '../session-handler/thread-session-runtime.js';
10
10
  import { RebaseConflictError, DirtyWorktreeError, TargetDirtyWorktreeError, NothingToMergeError, } from '../errors.js';
11
+ import { WORKTREE_PREFIX } from '../message-formatting.js';
11
12
  const logger = createLogger(LogPrefix.WORKTREE);
12
13
  function quoteShellArg(value) {
13
14
  return `'${value.replaceAll("'", "'\\''")}'`;
14
15
  }
15
- /** Worktree thread title prefix - indicates unmerged worktree */
16
- export const WORKTREE_PREFIX = '⬦ ';
16
+ export { WORKTREE_PREFIX };
17
+ function worktreePrefixLength(name) {
18
+ if (name.startsWith(WORKTREE_PREFIX))
19
+ return WORKTREE_PREFIX.length;
20
+ return 0;
21
+ }
17
22
  async function removeWorktreePrefixFromTitle(thread) {
18
- if (!thread.name.startsWith(WORKTREE_PREFIX)) {
23
+ const prefixLength = worktreePrefixLength(thread.name);
24
+ if (!prefixLength) {
19
25
  return;
20
26
  }
21
- const newName = thread.name.slice(WORKTREE_PREFIX.length);
27
+ const newName = thread.name.slice(prefixLength);
22
28
  const timeoutMs = 5000;
23
29
  await Promise.race([
24
30
  thread.setName(newName).catch((e) => {
@@ -120,8 +126,9 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
120
126
  return;
121
127
  }
122
128
  if (result instanceof RebaseConflictError) {
123
- const mergedThreadName = thread.name.startsWith(WORKTREE_PREFIX)
124
- ? thread.name.slice(WORKTREE_PREFIX.length)
129
+ const prefixLength = worktreePrefixLength(thread.name);
130
+ const mergedThreadName = prefixLength
131
+ ? thread.name.slice(prefixLength)
125
132
  : thread.name;
126
133
  const mergeCommand = [
127
134
  'kimaki merge-worktree',
@@ -138,11 +138,12 @@ export async function showModelVariantPicker({ channel, appId, editReply, }) {
138
138
  const { providerID, modelID, model: fullModelId } = currentModelInfo;
139
139
  const sourceLabel = formatSourceLabel(currentModelInfo);
140
140
  const variantLabel = cascadeVariant ? ` (${cascadeVariant})` : '';
141
- const displayedModelId = resolveDisplayedModelId({
141
+ const displayedModelId = (await resolveDisplayedModelId({
142
142
  providers: providersResponse.data.all,
143
143
  providerID,
144
144
  modelID,
145
- }) ?? fullModelId;
145
+ sessionID: sessionId,
146
+ })) ?? fullModelId;
146
147
  const provider = providersResponse.data.all.find((p) => {
147
148
  return p.id === providerID;
148
149
  });