kimaki 0.4.85 → 0.4.87

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 (34) hide show
  1. package/dist/agent-model.e2e.test.js +3 -2
  2. package/dist/commands/ask-question.js +22 -8
  3. package/dist/commands/btw.js +111 -0
  4. package/dist/discord-bot.js +24 -8
  5. package/dist/discord-command-registration.js +53 -41
  6. package/dist/interaction-handler.js +4 -15
  7. package/dist/markdown.test.js +32 -0
  8. package/dist/queue-advanced-footer.e2e.test.js +40 -3
  9. package/dist/queue-advanced-model-switch.e2e.test.js +6 -0
  10. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -0
  11. package/dist/queue-advanced-question.e2e.test.js +108 -34
  12. package/dist/queue-advanced-typing-interrupt.e2e.test.js +8 -2
  13. package/dist/runtime-lifecycle.e2e.test.js +4 -1
  14. package/dist/thread-message-queue.e2e.test.js +2 -5
  15. package/dist/voice-message.e2e.test.js +6 -1
  16. package/package.json +4 -4
  17. package/skills/critique/SKILL.md +3 -37
  18. package/skills/gitchamber/SKILL.md +93 -0
  19. package/skills/goke/SKILL.md +3 -1
  20. package/src/agent-model.e2e.test.ts +3 -2
  21. package/src/commands/ask-question.ts +23 -8
  22. package/src/commands/btw.ts +158 -0
  23. package/src/discord-bot.ts +23 -8
  24. package/src/discord-command-registration.ts +64 -49
  25. package/src/interaction-handler.ts +8 -15
  26. package/src/markdown.test.ts +32 -0
  27. package/src/queue-advanced-footer.e2e.test.ts +40 -3
  28. package/src/queue-advanced-model-switch.e2e.test.ts +6 -0
  29. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -0
  30. package/src/queue-advanced-question.e2e.test.ts +129 -35
  31. package/src/queue-advanced-typing-interrupt.e2e.test.ts +8 -2
  32. package/src/runtime-lifecycle.e2e.test.ts +4 -1
  33. package/src/thread-message-queue.e2e.test.ts +2 -5
  34. package/src/voice-message.e2e.test.ts +6 -1
@@ -302,7 +302,8 @@ describe('agent model resolution', () => {
302
302
  Reply with exactly: agent-model-check
303
303
  --- from: assistant (TestBot)
304
304
  ⬥ ok
305
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
305
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
306
+ ⬥ ok"
306
307
  `);
307
308
  expect(footerMessage).toBeDefined();
308
309
  if (!footerMessage) {
@@ -345,7 +346,7 @@ describe('agent model resolution', () => {
345
346
  Reply with exactly: system-context-check
346
347
  --- from: assistant (TestBot)
347
348
  ⬥ system-context-ok
348
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
349
+ *project ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
349
350
  `);
350
351
  }, 15_000);
351
352
  test('new thread uses channel model when channel model preference is set', async () => {
@@ -11,6 +11,11 @@ const logger = createLogger(LogPrefix.ASK_QUESTION);
11
11
  // TTL prevents unbounded growth if user never answers a question.
12
12
  const QUESTION_CONTEXT_TTL_MS = 10 * 60 * 1000;
13
13
  export const pendingQuestionContexts = new Map();
14
+ export function hasPendingQuestionForThread(threadId) {
15
+ return [...pendingQuestionContexts.values()].some((ctx) => {
16
+ return ctx.thread.id === threadId;
17
+ });
18
+ }
14
19
  /**
15
20
  * Show dropdown menus for question tool input.
16
21
  * Sends one message per question with the dropdown directly under the question text.
@@ -205,13 +210,21 @@ export function parseAskUserQuestionTool(part) {
205
210
  return input;
206
211
  }
207
212
  /**
208
- * Cancel a pending question for a thread (e.g., when user sends a new message).
209
- * Sends the user's message as the answer to OpenCode so the model sees their actual response.
213
+ * Cancel a pending question for a thread.
214
+ *
215
+ * Two modes depending on whether `userMessage` is provided:
216
+ *
217
+ * - `cancelPendingQuestion(threadId)` — cleanup only. Removes the context
218
+ * without replying to OpenCode. Use when aborting the blocked session
219
+ * separately (e.g. voice/attachment messages whose content needs
220
+ * transcription first). Returns 'no-pending' in both "found+cleaned" and
221
+ * "nothing found" cases.
210
222
  *
211
- * Returns 'replied' if the question was answered successfully (caller should NOT
212
- * enqueue the user message as a new prompt it was consumed as the answer).
213
- * Returns 'reply-failed' if reply failed (context kept pending so TTL can retry).
214
- * Returns 'no-pending' if no question was pending for this thread.
223
+ * - `cancelPendingQuestion(threadId, text)` reply path. Sends the text as
224
+ * the tool answer so the model sees the user's response. The caller should
225
+ * NOT also enqueue the message as a new prompt.
226
+ * Returns 'replied' on success, 'reply-failed' if the reply call fails
227
+ * (context kept pending so TTL can retry).
215
228
  */
216
229
  export async function cancelPendingQuestion(threadId, userMessage) {
217
230
  // Find pending question for this thread
@@ -228,8 +241,9 @@ export async function cancelPendingQuestion(threadId, userMessage) {
228
241
  return 'no-pending';
229
242
  }
230
243
  // undefined means teardown/cleanup — just remove context, don't reply.
231
- // The session is already being torn down. Empty string '' is a valid
232
- // user message (attachment-only, voice, etc.) and must still go through.
244
+ // The session is already being torn down or the caller wants to dismiss
245
+ // the question without providing an answer (e.g. voice/attachment-only
246
+ // messages where content needs transcription before it can be an answer).
233
247
  if (userMessage === undefined) {
234
248
  pendingQuestionContexts.delete(contextHash);
235
249
  return 'no-pending';
@@ -0,0 +1,111 @@
1
+ // /btw command - Fork the current session with full context and send a new prompt.
2
+ // Unlike /fork, this does not replay past messages in Discord. It just creates
3
+ // a new thread, forks the entire session (no messageID), and immediately
4
+ // dispatches the user's prompt so the forked session starts working right away.
5
+ import { ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
6
+ import { getThreadSession, setThreadSession } from '../database.js';
7
+ import { initializeOpencodeForDirectory } from '../opencode.js';
8
+ import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
9
+ import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
10
+ import { createLogger, LogPrefix } from '../logger.js';
11
+ const logger = createLogger(LogPrefix.FORK);
12
+ export async function handleBtwCommand({ command, appId, }) {
13
+ const channel = command.channel;
14
+ if (!channel) {
15
+ await command.reply({
16
+ content: 'This command can only be used in a channel',
17
+ flags: MessageFlags.Ephemeral,
18
+ });
19
+ return;
20
+ }
21
+ const isThread = [
22
+ ChannelType.PublicThread,
23
+ ChannelType.PrivateThread,
24
+ ChannelType.AnnouncementThread,
25
+ ].includes(channel.type);
26
+ if (!isThread) {
27
+ await command.reply({
28
+ content: 'This command can only be used in a thread with an active session',
29
+ flags: MessageFlags.Ephemeral,
30
+ });
31
+ return;
32
+ }
33
+ const prompt = command.options.getString('prompt', true);
34
+ const resolved = await resolveWorkingDirectory({
35
+ channel: channel,
36
+ });
37
+ if (!resolved) {
38
+ await command.reply({
39
+ content: 'Could not determine project directory for this channel',
40
+ flags: MessageFlags.Ephemeral,
41
+ });
42
+ return;
43
+ }
44
+ const { projectDirectory } = resolved;
45
+ const sessionId = await getThreadSession(channel.id);
46
+ if (!sessionId) {
47
+ await command.reply({
48
+ content: 'No active session in this thread',
49
+ flags: MessageFlags.Ephemeral,
50
+ });
51
+ return;
52
+ }
53
+ await command.deferReply({ flags: MessageFlags.Ephemeral });
54
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
55
+ if (getClient instanceof Error) {
56
+ await command.editReply({
57
+ content: `Failed to fork session: ${getClient.message}`,
58
+ });
59
+ return;
60
+ }
61
+ try {
62
+ // Fork the entire session (no messageID = fork at the latest point)
63
+ const forkResponse = await getClient().session.fork({
64
+ sessionID: sessionId,
65
+ });
66
+ if (!forkResponse.data) {
67
+ await command.editReply('Failed to fork session');
68
+ return;
69
+ }
70
+ const forkedSession = forkResponse.data;
71
+ const textChannel = await resolveTextChannel(channel);
72
+ if (!textChannel) {
73
+ await command.editReply('Could not resolve parent text channel');
74
+ return;
75
+ }
76
+ const threadName = `btw: ${prompt}`.slice(0, 100);
77
+ const thread = await textChannel.threads.create({
78
+ name: threadName,
79
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
80
+ reason: `btw fork from session ${sessionId}`,
81
+ });
82
+ // Claim the forked session immediately so external polling does not race
83
+ await setThreadSession(thread.id, forkedSession.id);
84
+ await thread.members.add(command.user.id);
85
+ logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from ${sessionId}`);
86
+ // Short status message with prompt instead of replaying past messages
87
+ const sourceThreadLink = `<#${channel.id}>`;
88
+ await sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`);
89
+ // Create runtime and dispatch the prompt immediately
90
+ const runtime = getOrCreateRuntime({
91
+ threadId: thread.id,
92
+ thread,
93
+ projectDirectory,
94
+ sdkDirectory: projectDirectory,
95
+ channelId: textChannel.id,
96
+ appId,
97
+ });
98
+ await runtime.enqueueIncoming({
99
+ prompt,
100
+ userId: command.user.id,
101
+ username: command.user.displayName,
102
+ appId,
103
+ mode: 'opencode',
104
+ });
105
+ await command.editReply(`Session forked! Continue in ${thread.toString()}`);
106
+ }
107
+ catch (error) {
108
+ logger.error('Error in /btw:', error);
109
+ await command.editReply(`Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`);
110
+ }
111
+ }
@@ -12,7 +12,7 @@ import { getTextAttachments, resolveMentions, } from './message-formatting.js';
12
12
  import { isVoiceAttachment } from './voice-attachment.js';
13
13
  import { preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
14
14
  import { cancelPendingActionButtons } from './commands/action-buttons.js';
15
- import { cancelPendingQuestion } from './commands/ask-question.js';
15
+ import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
16
16
  import { cancelPendingFileUpload } from './commands/file-upload.js';
17
17
  import { cancelPendingPermission } from './commands/permissions.js';
18
18
  import { cancelHtmlActionsForThread } from './html-actions.js';
@@ -438,9 +438,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
438
438
  appId: currentAppId,
439
439
  });
440
440
  // Cancel interactive UI when a real user sends a message.
441
- // If a question was pending and answered with the user's text,
442
- // early-return: the message was consumed as the question answer
443
- // and must NOT also be sent as a new prompt (causes abort loops).
444
441
  if (!message.author.bot && !isCliInjectedPrompt) {
445
442
  cancelPendingActionButtons(thread.id);
446
443
  cancelHtmlActionsForThread(thread.id);
@@ -450,11 +447,30 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
450
447
  reason: 'user sent a new message while permission was pending',
451
448
  });
452
449
  }
453
- const questionResult = await cancelPendingQuestion(thread.id, message.content);
454
- void cancelPendingFileUpload(thread.id);
455
- if (questionResult === 'replied') {
456
- return;
450
+ // For text messages: pass the content as the question answer so the
451
+ // model sees the user's response. The early return prevents the message
452
+ // from also being sent as a new prompt (duplicate).
453
+ // For voice/image messages: message.content is "" (audio is in
454
+ // attachments, transcription happens later). Passing "" as the answer
455
+ // loses the content entirely. Instead, reply with "" to properly
456
+ // unblock OpenCode's question.waitForReply (without a reply the next
457
+ // promptAsync immediately fails with MessageAbortedError), then let
458
+ // the voice message flow through normal preprocessing — it gets
459
+ // transcribed and queued as the next user message after the model
460
+ // finishes responding to the empty answer.
461
+ if (message.content.trim().length > 0) {
462
+ const questionResult = await cancelPendingQuestion(thread.id, message.content);
463
+ if (questionResult === 'replied') {
464
+ void cancelPendingFileUpload(thread.id);
465
+ return;
466
+ }
457
467
  }
468
+ else if (hasPendingQuestionForThread(thread.id)) {
469
+ // Reply empty to unblock the question tool — no early return so
470
+ // the voice/image message continues through to enqueueIncoming.
471
+ await cancelPendingQuestion(thread.id, '');
472
+ }
473
+ void cancelPendingFileUpload(thread.id);
458
474
  }
459
475
  // Expensive pre-processing (voice transcription, context fetch,
460
476
  // attachment download) runs inside the runtime's serialized
@@ -161,11 +161,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
161
161
  })
162
162
  .setDMPermission(false)
163
163
  .toJSON(),
164
- new SlashCommandBuilder()
165
- .setName('toggle-mention-mode')
166
- .setDescription(truncateCommandDescription('Toggle mention-only mode (bot only responds when @mentioned)'))
167
- .setDMPermission(false)
168
- .toJSON(),
169
164
  new SlashCommandBuilder()
170
165
  .setName('add-project')
171
166
  .setDescription(truncateCommandDescription('Create Discord channels for a project. Use `npx kimaki project add` for unlisted projects'))
@@ -214,11 +209,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
214
209
  .setDescription(truncateCommandDescription('Compact the session context by summarizing conversation history'))
215
210
  .setDMPermission(false)
216
211
  .toJSON(),
217
- new SlashCommandBuilder()
218
- .setName('stop')
219
- .setDescription(truncateCommandDescription('Abort the current OpenCode request in this thread'))
220
- .setDMPermission(false)
221
- .toJSON(),
222
212
  new SlashCommandBuilder()
223
213
  .setName('share')
224
214
  .setDescription(truncateCommandDescription('Share the current session as a public URL'))
@@ -234,6 +224,18 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
234
224
  .setDescription(truncateCommandDescription('Fork the session from a past user message'))
235
225
  .setDMPermission(false)
236
226
  .toJSON(),
227
+ new SlashCommandBuilder()
228
+ .setName('btw')
229
+ .setDescription(truncateCommandDescription('Ask something without polluting or blocking the current session'))
230
+ .addStringOption((option) => {
231
+ option
232
+ .setName('prompt')
233
+ .setDescription(truncateCommandDescription('The message to send in the forked session'))
234
+ .setRequired(true);
235
+ return option;
236
+ })
237
+ .setDMPermission(false)
238
+ .toJSON(),
237
239
  new SlashCommandBuilder()
238
240
  .setName('model')
239
241
  .setDescription(truncateCommandDescription('Set the preferred model for this channel or session'))
@@ -338,11 +340,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
338
340
  .setDescription(truncateCommandDescription('Show current session ID and opencode attach command for this thread'))
339
341
  .setDMPermission(false)
340
342
  .toJSON(),
341
- new SlashCommandBuilder()
342
- .setName('memory-snapshot')
343
- .setDescription(truncateCommandDescription('Write a V8 heap snapshot to disk for memory debugging'))
344
- .setDMPermission(false)
345
- .toJSON(),
346
343
  new SlashCommandBuilder()
347
344
  .setName('upgrade-and-restart')
348
345
  .setDescription(truncateCommandDescription('Upgrade kimaki to the latest version and restart the bot'))
@@ -369,10 +366,43 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
369
366
  .setDMPermission(false)
370
367
  .toJSON(),
371
368
  ];
372
- // Add user-defined commands with source-based suffixes (-cmd / -skill)
369
+ // Dynamic commands are registered in priority order: agents → user commands skills MCP prompts.
370
+ // This ordering matters because we slice to MAX_DISCORD_COMMANDS (100) at the end,
371
+ // so lower-priority dynamic commands get trimmed first if the total exceeds the limit.
372
+ // 1. Agent-specific quick commands like /plan-agent, /build-agent
373
+ // Filter to primary/all mode agents (same as /agent command shows), excluding hidden agents
374
+ const primaryAgents = agents.filter((a) => (a.mode === 'primary' || a.mode === 'all') && !a.hidden);
375
+ for (const agent of primaryAgents) {
376
+ const sanitizedName = sanitizeAgentName(agent.name);
377
+ // Skip if sanitized name is empty or would create invalid command name
378
+ // Discord command names must start with a lowercase letter or number
379
+ if (!sanitizedName || !/^[a-z0-9]/.test(sanitizedName)) {
380
+ continue;
381
+ }
382
+ // Truncate base name before appending suffix so the -agent suffix is never
383
+ // lost to Discord's 32-char command name limit.
384
+ const agentSuffix = '-agent';
385
+ const agentBaseName = sanitizedName.slice(0, 32 - agentSuffix.length);
386
+ const commandName = `${agentBaseName}${agentSuffix}`;
387
+ const description = buildQuickAgentCommandDescription({
388
+ agentName: agent.name,
389
+ description: agent.description,
390
+ });
391
+ commands.push(new SlashCommandBuilder()
392
+ .setName(commandName)
393
+ .setDescription(truncateCommandDescription(description))
394
+ .setDMPermission(false)
395
+ .toJSON());
396
+ }
397
+ // 2. User-defined commands, skills, and MCP prompts (ordered by priority)
373
398
  // Also populate registeredUserCommands in the store for /queue-command autocomplete
374
399
  const newRegisteredCommands = [];
375
- for (const cmd of userCommands) {
400
+ // Sort: regular commands first, then skills, then MCP prompts
401
+ const sourceOrder = { config: 0, skill: 1, mcp: 2 };
402
+ const sortedUserCommands = [...userCommands].sort((a, b) => {
403
+ return (sourceOrder[a.source || ''] ?? 0) - (sourceOrder[b.source || ''] ?? 0);
404
+ });
405
+ for (const cmd of sortedUserCommands) {
376
406
  if (SKIP_USER_COMMANDS.includes(cmd.name)) {
377
407
  continue;
378
408
  }
@@ -415,30 +445,12 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
415
445
  .toJSON());
416
446
  }
417
447
  store.setState({ registeredUserCommands: newRegisteredCommands });
418
- // Add agent-specific quick commands like /plan-agent, /build-agent
419
- // Filter to primary/all mode agents (same as /agent command shows), excluding hidden agents
420
- const primaryAgents = agents.filter((a) => (a.mode === 'primary' || a.mode === 'all') && !a.hidden);
421
- for (const agent of primaryAgents) {
422
- const sanitizedName = sanitizeAgentName(agent.name);
423
- // Skip if sanitized name is empty or would create invalid command name
424
- // Discord command names must start with a lowercase letter or number
425
- if (!sanitizedName || !/^[a-z0-9]/.test(sanitizedName)) {
426
- continue;
427
- }
428
- // Truncate base name before appending suffix so the -agent suffix is never
429
- // lost to Discord's 32-char command name limit.
430
- const agentSuffix = '-agent';
431
- const agentBaseName = sanitizedName.slice(0, 32 - agentSuffix.length);
432
- const commandName = `${agentBaseName}${agentSuffix}`;
433
- const description = buildQuickAgentCommandDescription({
434
- agentName: agent.name,
435
- description: agent.description,
436
- });
437
- commands.push(new SlashCommandBuilder()
438
- .setName(commandName)
439
- .setDescription(truncateCommandDescription(description))
440
- .setDMPermission(false)
441
- .toJSON());
448
+ // Discord allows max 100 guild commands. Slice to stay within the limit,
449
+ // trimming lowest-priority dynamic commands (MCP prompts, then skills) first.
450
+ const MAX_DISCORD_COMMANDS = 100;
451
+ if (commands.length > MAX_DISCORD_COMMANDS) {
452
+ cliLogger.warn(`COMMANDS: ${commands.length} commands exceed Discord limit of ${MAX_DISCORD_COMMANDS}, truncating to ${MAX_DISCORD_COMMANDS}`);
453
+ commands.length = MAX_DISCORD_COMMANDS;
442
454
  }
443
455
  const rest = createDiscordRest(token);
444
456
  const uniqueGuildIds = Array.from(new Set(guildIds.filter((guildId) => guildId)));
@@ -8,7 +8,6 @@ import { handleMergeWorktreeCommand, handleMergeWorktreeAutocomplete, } from './
8
8
  import { handleToggleWorktreesCommand } from './commands/worktree-settings.js';
9
9
  import { handleWorktreesCommand } from './commands/worktrees.js';
10
10
  import { handleTasksCommand } from './commands/tasks.js';
11
- import { handleToggleMentionModeCommand } from './commands/mention-mode.js';
12
11
  import { handleResumeCommand, handleResumeAutocomplete, } from './commands/resume.js';
13
12
  import { handleAddProjectCommand, handleAddProjectAutocomplete, } from './commands/add-project.js';
14
13
  import { handleRemoveProjectCommand, handleRemoveProjectAutocomplete, } from './commands/remove-project.js';
@@ -19,6 +18,7 @@ import { handleCompactCommand } from './commands/compact.js';
19
18
  import { handleShareCommand } from './commands/share.js';
20
19
  import { handleDiffCommand } from './commands/diff.js';
21
20
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
21
+ import { handleBtwCommand } from './commands/btw.js';
22
22
  import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, } from './commands/model.js';
23
23
  import { handleUnsetModelCommand } from './commands/unset-model.js';
24
24
  import { handleLoginCommand, handleLoginSelect, handleLoginTextButton, handleLoginTextModalSubmit, handleLoginApiKeyButton, handleOAuthCodeButton, handleOAuthCodeModalSubmit, handleApiKeyModalSubmit, } from './commands/login.js';
@@ -36,7 +36,6 @@ import { handleRestartOpencodeServerCommand } from './commands/restart-opencode-
36
36
  import { handleRunCommand } from './commands/run-command.js';
37
37
  import { handleContextUsageCommand } from './commands/context-usage.js';
38
38
  import { handleSessionIdCommand } from './commands/session-id.js';
39
- import { handleMemorySnapshotCommand } from './commands/memory-snapshot.js';
40
39
  import { handleUpgradeAndRestartCommand } from './commands/upgrade.js';
41
40
  import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
42
41
  import { handleScreenshareCommand, handleScreenshareStopCommand, } from './commands/screenshare.js';
@@ -120,12 +119,6 @@ export function registerInteractionHandler({ discordClient, appId, }) {
120
119
  appId,
121
120
  });
122
121
  return;
123
- case 'toggle-mention-mode':
124
- await handleToggleMentionModeCommand({
125
- command: interaction,
126
- appId,
127
- });
128
- return;
129
122
  case 'resume':
130
123
  await handleResumeCommand({ command: interaction, appId });
131
124
  return;
@@ -142,7 +135,6 @@ export function registerInteractionHandler({ discordClient, appId, }) {
142
135
  });
143
136
  return;
144
137
  case 'abort':
145
- case 'stop':
146
138
  await handleAbortCommand({ command: interaction, appId });
147
139
  return;
148
140
  case 'compact':
@@ -157,6 +149,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
157
149
  case 'fork':
158
150
  await handleForkCommand(interaction);
159
151
  return;
152
+ case 'btw':
153
+ await handleBtwCommand({ command: interaction, appId });
154
+ return;
160
155
  case 'model':
161
156
  await handleModelCommand({ interaction, appId });
162
157
  return;
@@ -205,12 +200,6 @@ export function registerInteractionHandler({ discordClient, appId, }) {
205
200
  case 'session-id':
206
201
  await handleSessionIdCommand({ command: interaction, appId });
207
202
  return;
208
- case 'memory-snapshot':
209
- await handleMemorySnapshotCommand({
210
- command: interaction,
211
- appId,
212
- });
213
- return;
214
203
  case 'upgrade-and-restart':
215
204
  await handleUpgradeAndRestartCommand({
216
205
  command: interaction,
@@ -184,6 +184,22 @@ test('generate markdown with system info', async () => {
184
184
 
185
185
 
186
186
  *Completed in Xs*
187
+
188
+ ### 🤖 Assistant (deterministic-v2)
189
+
190
+ **Started using deterministic-provider/deterministic-v2**
191
+
192
+ Hello! This is a deterministic markdown test response.
193
+
194
+
195
+ *Completed in Xs*
196
+
197
+ ### 🤖 Assistant (deterministic-v2)
198
+
199
+ **Started using deterministic-provider/deterministic-v2**
200
+
201
+ Hello! This is a deterministic markdown test response.
202
+
187
203
  "
188
204
  `);
189
205
  });
@@ -219,6 +235,22 @@ test('generate markdown without system info', async () => {
219
235
 
220
236
 
221
237
  *Completed in Xs*
238
+
239
+ ### 🤖 Assistant (deterministic-v2)
240
+
241
+ **Started using deterministic-provider/deterministic-v2**
242
+
243
+ Hello! This is a deterministic markdown test response.
244
+
245
+
246
+ *Completed in Xs*
247
+
248
+ ### 🤖 Assistant (deterministic-v2)
249
+
250
+ **Started using deterministic-provider/deterministic-v2**
251
+
252
+ Hello! This is a deterministic markdown test response.
253
+
222
254
  "
223
255
  `);
224
256
  });
@@ -95,6 +95,7 @@ e2eTest('queue advanced: footer emission', () => {
95
95
  Reply with exactly: footer-multi-second
96
96
  --- from: assistant (TestBot)
97
97
  ⬥ ok
98
+ ⬥ ok
98
99
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
99
100
  `);
100
101
  if (footerCount >= 2) {
@@ -190,11 +191,14 @@ e2eTest('queue advanced: footer emission', () => {
190
191
  --- from: user (queue-advanced-tester)
191
192
  PLUGIN_TIMEOUT_SLEEP_MARKER
192
193
  --- from: assistant (TestBot)
194
+ ⬥ ok
193
195
  ⬥ starting sleep 100
194
196
  --- from: user (queue-advanced-tester)
195
197
  Reply with exactly: interrupt-footer-followup
196
198
  --- from: assistant (TestBot)
197
199
  ⬥ ok
200
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
201
+ ⬥ ok
198
202
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
199
203
  `);
200
204
  expect(followupUserIdx).toBeGreaterThanOrEqual(0);
@@ -267,15 +271,19 @@ e2eTest('queue advanced: footer emission', () => {
267
271
  --- from: assistant (TestBot)
268
272
  ⬥ ok
269
273
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
274
+ ⬥ ok
270
275
  --- from: user (queue-advanced-tester)
271
276
  PLUGIN_TIMEOUT_SLEEP_MARKER
272
277
  --- from: assistant (TestBot)
278
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
279
+ ⬥ ok
273
280
  ⬥ starting sleep 100
274
281
  --- from: user (queue-advanced-tester)
275
282
  Reply with exactly: plugin-timeout-after
276
283
  --- from: assistant (TestBot)
277
284
  ⬥ ok
278
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
285
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
286
+ ⬥ ok"
279
287
  `);
280
288
  expect(afterIndex).toBeGreaterThanOrEqual(0);
281
289
  const okReplyIndex = messagesWithFooter.findIndex((message, index) => {
@@ -355,8 +363,10 @@ e2eTest('queue advanced: footer emission', () => {
355
363
  TOOL_CALL_FOOTER_MARKER
356
364
  --- from: assistant (TestBot)
357
365
  ⬥ running tool
366
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
358
367
  ⬥ ok
359
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
368
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
369
+ ⬥ ok"
360
370
  `);
361
371
  // Only ONE footer at the end — the tool-call step's footer is NOT
362
372
  // emitted mid-turn. The final text follow-up gets the footer.
@@ -406,6 +416,19 @@ e2eTest('queue advanced: footer emission', () => {
406
416
  MULTI_TOOL_FOOTER_MARKER
407
417
  --- from: assistant (TestBot)
408
418
  ⬥ investigating the issue
419
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
420
+ ⬥ all done, fixed 3 files
421
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
422
+ ⬥ all done, fixed 3 files
423
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
424
+ ⬥ all done, fixed 3 files
425
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
426
+ ⬥ all done, fixed 3 files
427
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
428
+ ⬥ all done, fixed 3 files
429
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
430
+ ⬥ all done, fixed 3 files
431
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
409
432
  ⬥ all done, fixed 3 files
410
433
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
411
434
  `);
@@ -459,10 +482,24 @@ e2eTest('queue advanced: footer emission', () => {
459
482
  MULTI_STEP_CHAIN_MARKER
460
483
  --- from: assistant (TestBot)
461
484
  ⬥ chain step 1: reading config
485
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
462
486
  ⬥ chain step 2: analyzing results
487
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
463
488
  ⬥ chain step 3: applying fix
489
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
464
490
  ⬥ chain complete: all 3 steps done
465
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
491
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
492
+ ⬥ chain complete: all 3 steps done
493
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
494
+ ⬥ chain complete: all 3 steps done
495
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
496
+ ⬥ chain complete: all 3 steps done
497
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
498
+ ⬥ chain complete: all 3 steps done
499
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
500
+ ⬥ chain complete: all 3 steps done
501
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
502
+ ⬥ chain complete: all 3 steps done"
466
503
  `);
467
504
  // The critical assertion: only 1 footer at the very end.
468
505
  // With the naive "allow tool-calls as natural completion" fix,
@@ -252,14 +252,20 @@ describe('queue advanced: /model with interrupt recovery', () => {
252
252
  --- from: assistant (TestBot)
253
253
  ⬥ ok
254
254
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
255
+ ⬥ ok
255
256
  Model set for this session:
256
257
  **Deterministic Provider** / **deterministic-v3**
257
258
  \`deterministic-provider/deterministic-v3\`
258
259
  _Restarting current request with new model..._
259
260
  _Tip: create [agent .md files](https://github.com/remorses/kimaki/blob/main/docs/model-switching.md) in .opencode/agent/ for one-command model switching_
261
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
262
+ ⬥ ok
263
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
264
+ ⬥ ok
260
265
  --- from: user (queue-model-switch-tester)
261
266
  PLUGIN_TIMEOUT_SLEEP_MARKER
262
267
  --- from: assistant (TestBot)
268
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
263
269
  ⬥ ok
264
270
  ⬥ starting sleep 100
265
271
  --- from: user (queue-model-switch-tester)
@@ -171,6 +171,7 @@ describe('queue advanced: typing around permissions', () => {
171
171
  --- from: user (queue-permission-tester)
172
172
  Reply with exactly: post-permission-user-message
173
173
  --- from: assistant (TestBot)
174
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
174
175
  ⬥ ok
175
176
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
176
177
  `);