kimaki 0.11.0 → 0.12.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 (54) hide show
  1. package/dist/agent-model.e2e.test.js +91 -1
  2. package/dist/cli-runner.js +28 -10
  3. package/dist/cli.js +5 -0
  4. package/dist/commands/agent.js +14 -16
  5. package/dist/commands/mention-mode.js +0 -1
  6. package/dist/commands/model-variant.js +2 -2
  7. package/dist/commands/model.js +47 -27
  8. package/dist/commands/unset-model.js +2 -2
  9. package/dist/commands/verbosity.js +0 -1
  10. package/dist/commands/worktree-settings.js +0 -1
  11. package/dist/discord-bot.js +10 -5
  12. package/dist/discord-command-registration.js +1 -1
  13. package/dist/discord-utils.js +14 -0
  14. package/dist/discord-utils.test.js +51 -1
  15. package/dist/external-opencode-sync.js +119 -54
  16. package/dist/interaction-handler.js +4 -0
  17. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +1 -1
  18. package/dist/message-formatting.js +91 -0
  19. package/dist/message-formatting.test.js +206 -1
  20. package/dist/opencode.js +34 -158
  21. package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
  22. package/dist/session-handler/thread-session-runtime.js +11 -3
  23. package/dist/store.js +1 -0
  24. package/dist/system-message.test.js +16 -0
  25. package/dist/voice-handler.js +91 -68
  26. package/package.json +6 -6
  27. package/skills/npm-package/SKILL.md +12 -2
  28. package/skills/termcast/SKILL.md +32 -846
  29. package/skills/tuistory/SKILL.md +71 -0
  30. package/src/agent-model.e2e.test.ts +117 -0
  31. package/src/cli-runner.ts +28 -10
  32. package/src/cli.ts +11 -0
  33. package/src/commands/agent.ts +14 -17
  34. package/src/commands/mention-mode.ts +0 -1
  35. package/src/commands/model-variant.ts +2 -2
  36. package/src/commands/model.ts +63 -37
  37. package/src/commands/unset-model.ts +1 -2
  38. package/src/commands/verbosity.ts +0 -1
  39. package/src/commands/worktree-settings.ts +0 -1
  40. package/src/discord-bot.ts +11 -4
  41. package/src/discord-command-registration.ts +1 -1
  42. package/src/discord-utils.test.ts +63 -2
  43. package/src/discord-utils.ts +19 -0
  44. package/src/external-opencode-sync.ts +147 -64
  45. package/src/interaction-handler.ts +5 -0
  46. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +1 -1
  47. package/src/message-formatting.test.ts +247 -1
  48. package/src/message-formatting.ts +93 -1
  49. package/src/opencode.ts +36 -152
  50. package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
  51. package/src/session-handler/thread-session-runtime.ts +11 -2
  52. package/src/store.ts +9 -0
  53. package/src/system-message.test.ts +16 -0
  54. package/src/voice-handler.ts +106 -78
@@ -19,7 +19,7 @@ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provid
19
19
  import { setDataDir } from './config.js';
20
20
  import { store } from './store.js';
21
21
  import { startDiscordBot } from './discord-bot.js';
22
- import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, } from './database.js';
22
+ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, getThreadSession, getSessionAgent, getChannelAgent, } from './database.js';
23
23
  import { getDb } from './db.js';
24
24
  import * as orm from 'drizzle-orm';
25
25
  import * as schema from './schema.js';
@@ -258,6 +258,10 @@ describe('agent model resolution', () => {
258
258
  description: `Switch to ${agentName} agent`,
259
259
  }))
260
260
  .setDMPermission(false)
261
+ .addStringOption((opt) => opt
262
+ .setName('prompt')
263
+ .setDescription('Send a prompt with this agent')
264
+ .setRequired(false))
261
265
  .toJSON();
262
266
  });
263
267
  const rest = new REST({ version: '10', api: discord.restUrl }).setToken(discord.botToken);
@@ -681,6 +685,92 @@ describe('agent model resolution', () => {
681
685
  expect(secondFooter.content).toContain(DEFAULT_MODEL);
682
686
  expect(secondFooter.content).not.toContain(AGENT_MODEL);
683
687
  }, 20_000);
688
+ test('/plan-agent with prompt starts a session with the plan agent', async () => {
689
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
690
+ const prompt = 'Reply with exactly: inline-plan-agent-msg';
691
+ const { id: interactionId } = await discord
692
+ .channel(TEXT_CHANNEL_ID)
693
+ .user(TEST_USER_ID)
694
+ .runSlashCommand({
695
+ name: 'plan-agent',
696
+ options: [{ name: 'prompt', type: 3, value: prompt }],
697
+ });
698
+ await discord
699
+ .channel(TEXT_CHANNEL_ID)
700
+ .waitForInteractionAck({ interactionId, timeout: 4_000 });
701
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
702
+ timeout: 4_000,
703
+ predicate: (t) => {
704
+ return t.name === prompt;
705
+ },
706
+ });
707
+ await waitForFooterMessage({
708
+ discord,
709
+ threadId: thread.id,
710
+ timeout: 4_000,
711
+ afterMessageIncludes: 'ok',
712
+ afterAuthorId: discord.botUserId,
713
+ });
714
+ const sessionId = await getThreadSession(thread.id);
715
+ expect(sessionId).toBeDefined();
716
+ expect(sessionId ? await getSessionAgent(sessionId) : undefined).toBe('plan');
717
+ expect(await getChannelAgent(TEXT_CHANNEL_ID)).toBe('test-agent');
718
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
719
+ "--- from: assistant (TestBot)
720
+ » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
721
+ *using deterministic-provider/plan-model-v2 ⋅ plan*
722
+ ⬥ ok
723
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
724
+ `);
725
+ }, 20_000);
726
+ test('/plan-agent with prompt in an existing thread changes the session agent', async () => {
727
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
728
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
729
+ content: 'Reply with exactly: inline-existing-first-msg',
730
+ });
731
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
732
+ timeout: 4_000,
733
+ predicate: (t) => {
734
+ return t.name === 'Reply with exactly: inline-existing-first-msg';
735
+ },
736
+ });
737
+ await waitForFooterMessage({
738
+ discord,
739
+ threadId: thread.id,
740
+ timeout: 4_000,
741
+ afterMessageIncludes: 'ok',
742
+ afterAuthorId: discord.botUserId,
743
+ });
744
+ const prompt = 'Reply with exactly: inline-existing-plan-msg';
745
+ const th = discord.thread(thread.id);
746
+ const { id: interactionId } = await th.user(TEST_USER_ID).runSlashCommand({
747
+ name: 'plan-agent',
748
+ options: [{ name: 'prompt', type: 3, value: prompt }],
749
+ });
750
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
751
+ await waitForFooterMessage({
752
+ discord,
753
+ threadId: thread.id,
754
+ timeout: 4_000,
755
+ afterMessageIncludes: 'inline-existing-plan-msg',
756
+ afterAuthorId: discord.botUserId,
757
+ });
758
+ const sessionId = await getThreadSession(thread.id);
759
+ expect(sessionId).toBeDefined();
760
+ expect(sessionId ? await getSessionAgent(sessionId) : undefined).toBe('plan');
761
+ expect(await getChannelAgent(TEXT_CHANNEL_ID)).toBe('test-agent');
762
+ expect(await th.text()).toMatchInlineSnapshot(`
763
+ "--- from: user (agent-model-tester)
764
+ Reply with exactly: inline-existing-first-msg
765
+ --- from: assistant (TestBot)
766
+ *using deterministic-provider/agent-model-v2 ⋅ test-agent*
767
+ ⬥ ok
768
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
769
+ » **agent-model-tester** (plan): Reply with exactly: inline-existing-plan-msg
770
+ ⬥ ok
771
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
772
+ `);
773
+ }, 20_000);
684
774
  test('/plan-agent inside a thread switches the model for that thread', async () => {
685
775
  // 1. Start with test-agent on the channel
686
776
  await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
@@ -938,16 +938,34 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
938
938
  startCaffeinate();
939
939
  const forceRestartOnboarding = Boolean(restartOnboarding);
940
940
  const forceGateway = Boolean(gateway);
941
- // Step 0: Ensure bun is installed. OpenCode is downloaded on first run
942
- // to ~/.kimaki/bin/ (pinned version), so no install check is needed.
943
- await ensureCommandAvailable({
944
- name: 'bun',
945
- envPathKey: 'BUN_PATH',
946
- installUnix: 'curl -fsSL https://bun.sh/install | bash',
947
- installWindows: 'irm bun.sh/install.ps1 | iex',
948
- possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
949
- possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
950
- });
941
+ // Step 0: Ensure opencode and bun are installed
942
+ await Promise.all([
943
+ ensureCommandAvailable({
944
+ name: 'opencode',
945
+ envPathKey: 'OPENCODE_PATH',
946
+ installUnix: 'curl -fsSL https://opencode.ai/install | bash',
947
+ installWindows: 'irm https://opencode.ai/install.ps1 | iex',
948
+ possiblePathsUnix: [
949
+ '~/.local/bin/opencode',
950
+ '~/.opencode/bin/opencode',
951
+ '/usr/local/bin/opencode',
952
+ '/opt/opencode/bin/opencode',
953
+ ],
954
+ possiblePathsWindows: [
955
+ '~\\.local\\bin\\opencode.exe',
956
+ '~\\AppData\\Local\\opencode\\opencode.exe',
957
+ '~\\.opencode\\bin\\opencode.exe',
958
+ ],
959
+ }),
960
+ ensureCommandAvailable({
961
+ name: 'bun',
962
+ envPathKey: 'BUN_PATH',
963
+ installUnix: 'curl -fsSL https://bun.sh/install | bash',
964
+ installWindows: 'irm bun.sh/install.ps1 | iex',
965
+ possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
966
+ possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
967
+ }),
968
+ ]);
951
969
  void backgroundUpgradeKimaki();
952
970
  // Start in-process Hrana server before database init. Required for the bot
953
971
  // process because it serves as both the DB server and the single-instance
package/dist/cli.js CHANGED
@@ -40,6 +40,7 @@ cli
40
40
  .option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
41
41
  .option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
42
42
  .option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
43
+ .option('--disable-sync', 'Disable background sync of external OpenCode sessions into Discord')
43
44
  .option('--no-sentry', 'Disable Sentry error reporting')
44
45
  .option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
45
46
  .option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
@@ -136,6 +137,7 @@ cli
136
137
  ...(options.mentionMode && { defaultMentionMode: true }),
137
138
  ...(options.noCritique && { critiqueEnabled: false }),
138
139
  ...(options.allowAllUsers && { allowAllUsers: true }),
140
+ ...(options.disableSync && { syncEnabled: false }),
139
141
  ...(enabledSkills.length > 0 && { enabledSkills }),
140
142
  ...(disabledSkills.length > 0 && { disabledSkills }),
141
143
  });
@@ -157,6 +159,9 @@ cli
157
159
  if (options.noCritique) {
158
160
  cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
159
161
  }
162
+ if (options.disableSync) {
163
+ cliLogger.log('Background sync disabled: external OpenCode sessions will not appear in Discord');
164
+ }
160
165
  if (options.noSentry) {
161
166
  process.env.KIMAKI_SENTRY_DISABLED = '1';
162
167
  cliLogger.log('Sentry error reporting disabled (--no-sentry)');
@@ -1,7 +1,7 @@
1
1
  // /agent command - Set the preferred agent for this channel or session.
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
- // the prompt is sent as a one-shot with that agent without switching the persistent preference.
4
+ // the prompt is sent with that agent and the session keeps that agent afterwards.
5
5
  import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
6
6
  import crypto from 'node:crypto';
7
7
  import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, } from '../database.js';
@@ -162,7 +162,7 @@ export async function setAgentForContext({ context, agentName, }) {
162
162
  }
163
163
  }
164
164
  export async function handleAgentCommand({ interaction, appId, }) {
165
- await interaction.deferReply({ flags: MessageFlags.Ephemeral });
165
+ await interaction.deferReply();
166
166
  const context = await resolveAgentCommandContext({ interaction, appId });
167
167
  if (!context) {
168
168
  return;
@@ -290,13 +290,12 @@ export async function handleAgentSelectMenu(interaction) {
290
290
  export async function handleQuickAgentCommand({ command, appId, }) {
291
291
  const fallbackAgentName = command.commandName.replace(/-agent$/, '');
292
292
  const prompt = command.options.getString('prompt') || undefined;
293
- // One-shot prompt mode: send the prompt with a temporary agent override
294
- // without changing the persistent agent preference.
293
+ // Prompt mode: send the prompt with this agent immediately.
295
294
  if (prompt) {
296
295
  return handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt });
297
296
  }
298
297
  // No prompt: switch the persistent agent preference (original behavior).
299
- await command.deferReply({ flags: MessageFlags.Ephemeral });
298
+ await command.deferReply();
300
299
  const context = await resolveAgentCommandContext({
301
300
  interaction: command,
302
301
  appId,
@@ -363,10 +362,10 @@ export async function handleQuickAgentCommand({ command, appId, }) {
363
362
  }
364
363
  }
365
364
  /**
366
- * Handle one-shot prompt mode: send a prompt with a temporary agent override.
367
- * In a thread: enqueue the prompt with the agent override on the existing session.
368
- * In a channel: create a new thread and enqueue the prompt with the agent override.
369
- * Neither case changes the persistent agent preference.
365
+ * Handle prompt mode: send a prompt with the requested agent.
366
+ * In a thread: enqueue the prompt on the existing session and switch that session.
367
+ * In a channel: create a new thread whose session starts with the requested agent.
368
+ * Channel-level preferences are not changed.
370
369
  */
371
370
  async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt, }) {
372
371
  const channel = command.channel;
@@ -386,7 +385,7 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
386
385
  ].includes(channel.type);
387
386
  const displayText = `${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
388
387
  if (isThread) {
389
- // In a thread: enqueue the prompt on the existing session with agent override.
388
+ // In a thread: enqueue the prompt and switch the existing session to this agent.
390
389
  const thread = channel;
391
390
  const resolved = await resolveWorkingDirectory({ channel: thread });
392
391
  if (!resolved) {
@@ -419,9 +418,8 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
419
418
  });
420
419
  }
421
420
  else if (channel.type === ChannelType.GuildText) {
422
- // In a channel: create a new thread and enqueue with the agent override.
423
- const textChannel = channel;
424
- const metadata = await getKimakiMetadata(textChannel);
421
+ // In a channel: create a new thread and enqueue with the requested agent.
422
+ const metadata = await getKimakiMetadata(channel);
425
423
  const projectDirectory = metadata.projectDirectory;
426
424
  if (!projectDirectory) {
427
425
  await command.reply({
@@ -431,14 +429,14 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
431
429
  return;
432
430
  }
433
431
  await command.deferReply();
434
- const starterMessage = await textChannel.send({
432
+ const starterMessage = await channel.send({
435
433
  content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
436
434
  flags: SILENT_MESSAGE_FLAGS,
437
435
  });
438
436
  const thread = await starterMessage.startThread({
439
437
  name: prompt.slice(0, 80),
440
438
  autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
441
- reason: `One-shot ${resolvedAgentName} agent prompt`,
439
+ reason: `${resolvedAgentName} agent prompt`,
442
440
  });
443
441
  await thread.members.add(command.user.id);
444
442
  await command.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`);
@@ -447,7 +445,7 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
447
445
  thread,
448
446
  projectDirectory,
449
447
  sdkDirectory: projectDirectory,
450
- channelId: textChannel.id,
448
+ channelId: channel.id,
451
449
  appId,
452
450
  });
453
451
  await runtime.enqueueIncoming({
@@ -38,6 +38,5 @@ export async function handleToggleMentionModeCommand({ command, }) {
38
38
  content: nextEnabled
39
39
  ? `Mention mode **enabled** for this channel.\nThe bot will only start new sessions when @mentioned.\nMessages in existing threads are not affected.`
40
40
  : `Mention mode **disabled** for this channel.\nThe bot will respond to all messages in **#${channel.name}**.`,
41
- flags: MessageFlags.Ephemeral,
42
41
  });
43
42
  }
@@ -40,7 +40,7 @@ function formatSourceLabel(info) {
40
40
  }
41
41
  }
42
42
  export async function handleModelVariantCommand({ interaction, appId, }) {
43
- await interaction.deferReply({ flags: MessageFlags.Ephemeral });
43
+ await interaction.deferReply();
44
44
  const channel = interaction.channel;
45
45
  if (!channel) {
46
46
  await interaction.editReply({
@@ -292,7 +292,7 @@ export async function handleVariantScopeSelectMenu(interaction) {
292
292
  async function applyVariant({ interaction, context, variant, scope, contextHash, }) {
293
293
  const modelId = context.modelId;
294
294
  const variantSuffix = variant ? ` (${variant})` : '';
295
- const agentTip = '\n_Tip: create [agent .md files](https://github.com/remorses/kimaki/blob/main/docs/model-switching.md) in .opencode/agent/ for one-command model switching_';
295
+ const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_';
296
296
  try {
297
297
  if (scope === 'session') {
298
298
  if (!context.sessionId) {
@@ -11,6 +11,20 @@ import { createLogger, LogPrefix } from '../logger.js';
11
11
  import * as errore from 'errore';
12
12
  import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
13
13
  const modelLogger = createLogger(LogPrefix.MODEL);
14
+ function buildSafeSelectOption({ label, value, description, }) {
15
+ const trimmedLabel = label?.trim();
16
+ const trimmedValue = value?.trim();
17
+ const safeLabel = (trimmedLabel || trimmedValue || 'Unknown').slice(0, 100);
18
+ const safeValue = trimmedValue || trimmedLabel || '';
19
+ if (!safeLabel || !safeValue) {
20
+ return undefined;
21
+ }
22
+ return {
23
+ label: safeLabel,
24
+ value: safeValue,
25
+ description: description?.slice(0, 100),
26
+ };
27
+ }
14
28
  // Store context by hash to avoid customId length limits (Discord max: 100 chars).
15
29
  // Entries are TTL'd to prevent unbounded growth when users open /model and never
16
30
  // interact with the select menu.
@@ -172,7 +186,7 @@ export async function getCurrentModelInfo({ sessionId, channelId, appId, agentPr
172
186
  export async function handleModelCommand({ interaction, appId, }) {
173
187
  modelLogger.log('[MODEL] handleModelCommand called');
174
188
  // Defer reply immediately to avoid 3-second timeout
175
- await interaction.deferReply({ flags: MessageFlags.Ephemeral });
189
+ await interaction.deferReply();
176
190
  modelLogger.log('[MODEL] Deferred reply');
177
191
  const channel = interaction.channel;
178
192
  if (!channel) {
@@ -307,15 +321,16 @@ export async function handleModelCommand({ interaction, appId, }) {
307
321
  const contextHash = crypto.randomBytes(8).toString('hex');
308
322
  setModelContext(contextHash, context);
309
323
  const allProviderOptions = [...availableProviders]
310
- .sort((a, b) => a.name.localeCompare(b.name))
324
+ .sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''))
311
325
  .map((provider) => {
312
326
  const modelCount = Object.keys(provider.models || {}).length;
313
- return {
314
- label: provider.name.slice(0, 100),
327
+ return buildSafeSelectOption({
328
+ label: provider.name,
315
329
  value: provider.id,
316
- description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
317
- };
318
- });
330
+ description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`,
331
+ });
332
+ })
333
+ .filter((option) => !!option);
319
334
  const { options } = buildPaginatedOptions({
320
335
  allOptions: allProviderOptions,
321
336
  page: 0,
@@ -383,15 +398,16 @@ export async function handleProviderSelectMenu(interaction) {
383
398
  const { all: allProviders, connected } = providersResponse.data;
384
399
  const availableProviders = allProviders.filter((p) => connected.includes(p.id));
385
400
  const allProviderOptions = [...availableProviders]
386
- .sort((a, b) => a.name.localeCompare(b.name))
401
+ .sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''))
387
402
  .map((p) => {
388
403
  const modelCount = Object.keys(p.models || {}).length;
389
- return {
390
- label: p.name.slice(0, 100),
404
+ return buildSafeSelectOption({
405
+ label: p.name,
391
406
  value: p.id,
392
- description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
393
- };
394
- });
407
+ description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`,
408
+ });
409
+ })
410
+ .filter((option) => !!option);
395
411
  const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: providerNavPage });
396
412
  const selectMenu = new StringSelectMenuBuilder()
397
413
  .setCustomId(`model_provider:${contextHash}`)
@@ -434,10 +450,11 @@ export async function handleProviderSelectMenu(interaction) {
434
450
  const models = Object.entries(provider.models || {})
435
451
  .map(([modelId, model]) => ({
436
452
  id: modelId,
437
- name: model.name,
453
+ name: model.name || modelId,
438
454
  releaseDate: model.release_date,
439
455
  }))
440
- .sort((a, b) => a.name.localeCompare(b.name));
456
+ .filter((model) => model.id && model.name)
457
+ .sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''));
441
458
  if (models.length === 0) {
442
459
  await interaction.editReply({
443
460
  content: `No models available for ${provider.name}`,
@@ -447,19 +464,21 @@ export async function handleProviderSelectMenu(interaction) {
447
464
  }
448
465
  // Update context with provider info and reuse the same hash
449
466
  context.providerId = selectedProviderId;
450
- context.providerName = provider.name;
467
+ context.providerName = provider.name?.trim() || provider.id;
451
468
  context.modelPage = 0;
452
469
  setModelContext(contextHash, context);
453
- const allModelOptions = models.map((model) => {
470
+ const allModelOptions = models
471
+ .map((model) => {
454
472
  const dateStr = model.releaseDate
455
473
  ? new Date(model.releaseDate).toLocaleDateString()
456
474
  : 'Unknown date';
457
- return {
458
- label: model.name.slice(0, 100),
475
+ return buildSafeSelectOption({
476
+ label: model.name,
459
477
  value: model.id,
460
- description: dateStr.slice(0, 100),
461
- };
462
- });
478
+ description: dateStr,
479
+ });
480
+ })
481
+ .filter((option) => !!option);
463
482
  const { options } = buildPaginatedOptions({
464
483
  allOptions: allModelOptions,
465
484
  page: 0,
@@ -527,13 +546,14 @@ export async function handleModelSelectMenu(interaction) {
527
546
  return;
528
547
  }
529
548
  const allModelOptions = Object.entries(provider.models || {})
530
- .map(([modelId, model]) => ({
531
- label: model.name.slice(0, 100),
549
+ .map(([modelId, model]) => buildSafeSelectOption({
550
+ label: model.name || modelId,
532
551
  value: modelId,
533
- description: (model.release_date
552
+ description: model.release_date
534
553
  ? new Date(model.release_date).toLocaleDateString()
535
- : 'Unknown date').slice(0, 100),
554
+ : 'Unknown date',
536
555
  }))
556
+ .filter((option) => !!option)
537
557
  .sort((a, b) => a.label.localeCompare(b.label));
538
558
  const { options } = buildPaginatedOptions({ allOptions: allModelOptions, page: modelNavPage });
539
559
  const selectMenu = new StringSelectMenuBuilder()
@@ -708,7 +728,7 @@ export async function handleModelScopeSelectMenu(interaction) {
708
728
  const modelDisplay = modelId.split('/')[1] || modelId;
709
729
  const variant = context.selectedVariant ?? null;
710
730
  const variantSuffix = variant ? ` (${variant})` : '';
711
- const agentTip = '\n_Tip: create [agent .md files](https://github.com/remorses/kimaki/blob/main/docs/model-switching.md) in .opencode/agent/ for one-command model switching_';
731
+ const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_';
712
732
  try {
713
733
  if (selectedScope === 'session') {
714
734
  if (!context.sessionId) {
@@ -1,5 +1,5 @@
1
1
  // /unset-model-override command - Remove model overrides and use default instead.
2
- import { ChatInputCommandInteraction, ChannelType, MessageFlags, } from 'discord.js';
2
+ import { ChatInputCommandInteraction, ChannelType, } from 'discord.js';
3
3
  import { getChannelModel, getSessionModel, getThreadSession, clearSessionModel, } from '../database.js';
4
4
  import { getDb } from '../db.js';
5
5
  import * as orm from 'drizzle-orm';
@@ -35,7 +35,7 @@ function formatModelSource(type, agentName) {
35
35
  */
36
36
  export async function handleUnsetModelCommand({ interaction, appId, }) {
37
37
  unsetModelLogger.log('[UNSET-MODEL] handleUnsetModelCommand called');
38
- await interaction.deferReply({ flags: MessageFlags.Ephemeral });
38
+ await interaction.deferReply();
39
39
  const channel = interaction.channel;
40
40
  if (!channel) {
41
41
  await interaction.editReply({
@@ -85,7 +85,6 @@ export async function handleVerbosityCommand({ command, }) {
85
85
  await command.reply({
86
86
  content: `**Verbosity**\nCurrent: \`${currentLevel}\` (${source})`,
87
87
  components: [actionRow],
88
- flags: MessageFlags.Ephemeral,
89
88
  });
90
89
  }
91
90
  /**
@@ -37,6 +37,5 @@ export async function handleToggleWorktreesCommand({ command, }) {
37
37
  content: nextEnabled
38
38
  ? `Worktrees **enabled** for this channel.\n\nNew sessions started from messages in **#${channel.name}** will now automatically create git worktrees.\n\nNew setting for **#${channel.name}**: **enabled**.`
39
39
  : `Worktrees **disabled** for this channel.\n\nNew sessions started from messages in **#${channel.name}** will use the main project directory.\n\nNew setting for **#${channel.name}**: **disabled**.`,
40
- flags: MessageFlags.Ephemeral,
41
40
  });
42
41
  }
@@ -6,7 +6,7 @@ import { stopOpencodeServer, } from './opencode.js';
6
6
  import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
7
7
  import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
8
8
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
9
- import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
9
+ import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
10
10
  import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
11
11
  import YAML from 'yaml';
12
12
  import { getTextAttachments, resolveMentions, } from './message-formatting.js';
@@ -304,7 +304,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
304
304
  // still need Kimaki permission so multi-agent orchestration stays opt-in.
305
305
  const isInjectedSelfBotMessage = isCliInjectedPrompt && message.author?.id === discordClient.user?.id;
306
306
  if (message.author?.bot && !isInjectedSelfBotMessage) {
307
- if (!hasKimakiBotPermission(message.member)) {
307
+ const member = await resolveGuildMessageMember(message);
308
+ if (!hasKimakiBotPermission(member, message.guild)) {
308
309
  return;
309
310
  }
310
311
  }
@@ -343,15 +344,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
343
344
  }
344
345
  }
345
346
  }
346
- if (!isCliInjectedPrompt && message.guild && message.member) {
347
- if (hasNoKimakiRole(message.member)) {
347
+ if (!isCliInjectedPrompt && message.guild) {
348
+ const member = await resolveGuildMessageMember(message);
349
+ if (!member) {
350
+ return;
351
+ }
352
+ if (hasNoKimakiRole(member)) {
348
353
  await message.reply({
349
354
  content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
350
355
  flags: SILENT_MESSAGE_FLAGS,
351
356
  });
352
357
  return;
353
358
  }
354
- if (!hasKimakiBotPermission(message.member)) {
359
+ if (!hasKimakiBotPermission(member, message.guild)) {
355
360
  await message.reply({
356
361
  content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
357
362
  flags: SILENT_MESSAGE_FLAGS,
@@ -395,7 +395,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
395
395
  .setDMPermission(false)
396
396
  .addStringOption((opt) => opt
397
397
  .setName('prompt')
398
- .setDescription('Send a one-shot prompt with this agent without switching')
398
+ .setDescription('Send a prompt with this agent')
399
399
  .setRequired(false))
400
400
  .toJSON());
401
401
  }
@@ -72,6 +72,20 @@ export function hasKimakiAdminPermission(member, guild) {
72
72
  const hasKimakiRole = hasRoleByName(member, 'kimaki', guild);
73
73
  return isOwner || isAdmin || canManageServer || hasKimakiRole;
74
74
  }
75
+ export async function resolveGuildMessageMember(message) {
76
+ if (!message.guild)
77
+ return null;
78
+ if (message.member)
79
+ return message.member;
80
+ const fetchedMember = await message.guild.members
81
+ .fetch(message.author.id)
82
+ .catch((e) => new Error('Failed to fetch guild member', { cause: e }));
83
+ if (fetchedMember instanceof Error) {
84
+ discordLogger.warn(`[PERMISSION] Denying message ${message.id}: ${fetchedMember.message}`);
85
+ return null;
86
+ }
87
+ return fetchedMember;
88
+ }
75
89
  function hasRoleByName(member, roleName, guild) {
76
90
  const target = roleName.toLowerCase();
77
91
  if (member instanceof GuildMember) {
@@ -1,6 +1,6 @@
1
1
  import { PermissionsBitField } from 'discord.js';
2
2
  import { afterEach, describe, expect, test } from 'vitest';
3
- import { hasKimakiAdminPermission, hasKimakiBotPermission, splitMarkdownForDiscord } from './discord-utils.js';
3
+ import { hasKimakiAdminPermission, hasKimakiBotPermission, resolveGuildMessageMember, splitMarkdownForDiscord, } from './discord-utils.js';
4
4
  import { store } from './store.js';
5
5
  describe('splitMarkdownForDiscord', () => {
6
6
  test('never returns chunks over the max length with code fences', () => {
@@ -198,3 +198,53 @@ describe('hasKimakiAdminPermission', () => {
198
198
  expect(hasKimakiAdminPermission(member, guild)).toBe(true);
199
199
  });
200
200
  });
201
+ describe('resolveGuildMessageMember', () => {
202
+ test('uses hydrated message member without fetching', async () => {
203
+ const member = { id: 'member-id' };
204
+ const message = {
205
+ guild: {
206
+ members: {
207
+ fetch() {
208
+ throw new Error('should not fetch');
209
+ },
210
+ },
211
+ },
212
+ member,
213
+ author: { id: 'member-id' },
214
+ id: 'message-id',
215
+ };
216
+ await expect(resolveGuildMessageMember(message)).resolves.toBe(member);
217
+ });
218
+ test('fetches missing guild message member', async () => {
219
+ const member = { id: 'member-id' };
220
+ const message = {
221
+ guild: {
222
+ members: {
223
+ fetch(id) {
224
+ expect(id).toBe('member-id');
225
+ return Promise.resolve(member);
226
+ },
227
+ },
228
+ },
229
+ member: null,
230
+ author: { id: 'member-id' },
231
+ id: 'message-id',
232
+ };
233
+ await expect(resolveGuildMessageMember(message)).resolves.toBe(member);
234
+ });
235
+ test('denies when missing guild message member cannot be fetched', async () => {
236
+ const message = {
237
+ guild: {
238
+ members: {
239
+ fetch() {
240
+ return Promise.reject(new Error('missing member'));
241
+ },
242
+ },
243
+ },
244
+ member: null,
245
+ author: { id: 'member-id' },
246
+ id: 'message-id',
247
+ };
248
+ await expect(resolveGuildMessageMember(message)).resolves.toBe(null);
249
+ });
250
+ });