kimaki 0.4.37 โ†’ 0.4.38

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.
@@ -6,7 +6,9 @@ import path from 'node:path';
6
6
  import { getDatabase } from './database.js';
7
7
  import { extractTagsArrays } from './xml.js';
8
8
  export async function ensureKimakiCategory(guild, botName) {
9
- const categoryName = botName ? `Kimaki ${botName}` : 'Kimaki';
9
+ // Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
10
+ const isKimakiBot = botName?.toLowerCase() === 'kimaki';
11
+ const categoryName = botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki';
10
12
  const existingCategory = guild.channels.cache.find((channel) => {
11
13
  if (channel.type !== ChannelType.GuildCategory) {
12
14
  return false;
@@ -22,7 +24,9 @@ export async function ensureKimakiCategory(guild, botName) {
22
24
  });
23
25
  }
24
26
  export async function ensureKimakiAudioCategory(guild, botName) {
25
- const categoryName = botName ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
27
+ // Skip appending bot name if it's already "kimaki" to avoid "Kimaki Audio kimaki"
28
+ const isKimakiBot = botName?.toLowerCase() === 'kimaki';
29
+ const categoryName = botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
26
30
  const existingCategory = guild.channels.cache.find((channel) => {
27
31
  if (channel.type !== ChannelType.GuildCategory) {
28
32
  return false;
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { spawn, spawnSync, execSync } from 'node:child_process';
14
14
  import http from 'node:http';
15
15
  import { setDataDir, getDataDir, getLockPort } from './config.js';
16
16
  import { extractTagsArrays } from './xml.js';
17
+ import { sanitizeAgentName } from './commands/agent.js';
17
18
  const cliLogger = createLogger('CLI');
18
19
  const cli = cac('kimaki');
19
20
  process.title = 'kimaki';
@@ -119,7 +120,7 @@ async function startLockServer() {
119
120
  const EXIT_NO_RESTART = 64;
120
121
  // Commands to skip when registering user commands (reserved names)
121
122
  const SKIP_USER_COMMANDS = ['init'];
122
- async function registerCommands(token, appId, userCommands = []) {
123
+ async function registerCommands({ token, appId, userCommands = [], agents = [], }) {
123
124
  const commands = [
124
125
  new SlashCommandBuilder()
125
126
  .setName('resume')
@@ -254,6 +255,18 @@ async function registerCommands(token, appId, userCommands = []) {
254
255
  })
255
256
  .toJSON());
256
257
  }
258
+ // Add agent-specific quick commands like /plan-agent, /build-agent
259
+ // Filter to primary/all mode agents (same as /agent command shows)
260
+ const primaryAgents = agents.filter((a) => a.mode === 'primary' || a.mode === 'all');
261
+ for (const agent of primaryAgents) {
262
+ const sanitizedName = sanitizeAgentName(agent.name);
263
+ const commandName = `${sanitizedName}-agent`;
264
+ const description = agent.description || `Switch to ${agent.name} agent`;
265
+ commands.push(new SlashCommandBuilder()
266
+ .setName(commandName.slice(0, 32)) // Discord limits to 32 chars
267
+ .setDescription(description.slice(0, 100))
268
+ .toJSON());
269
+ }
257
270
  const rest = new REST().setToken(token);
258
271
  try {
259
272
  const data = (await rest.put(Routes.applicationCommands(appId), {
@@ -506,8 +519,8 @@ async function run({ restart, addChannels }) {
506
519
  const getClient = await opencodePromise;
507
520
  s.stop('OpenCode server ready!');
508
521
  s.start('Fetching OpenCode data...');
509
- // Fetch projects and commands in parallel
510
- const [projects, allUserCommands] = await Promise.all([
522
+ // Fetch projects, commands, and agents in parallel
523
+ const [projects, allUserCommands, allAgents] = await Promise.all([
511
524
  getClient()
512
525
  .project.list({})
513
526
  .then((r) => r.data || [])
@@ -521,6 +534,10 @@ async function run({ restart, addChannels }) {
521
534
  .command.list({ query: { directory: currentDir } })
522
535
  .then((r) => r.data || [])
523
536
  .catch(() => []),
537
+ getClient()
538
+ .app.agents({ query: { directory: currentDir } })
539
+ .then((r) => r.data || [])
540
+ .catch(() => []),
524
541
  ]);
525
542
  s.stop(`Found ${projects.length} OpenCode project(s)`);
526
543
  const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
@@ -611,7 +628,7 @@ async function run({ restart, addChannels }) {
611
628
  note(`Found ${registrableCommands.length} user-defined command(s):\n${commandList}`, 'OpenCode Commands');
612
629
  }
613
630
  cliLogger.log('Registering slash commands asynchronously...');
614
- void registerCommands(token, appId, allUserCommands)
631
+ void registerCommands({ token, appId, userCommands: allUserCommands, agents: allAgents })
615
632
  .then(() => {
616
633
  cliLogger.log('Slash commands registered!');
617
634
  })
@@ -746,12 +763,6 @@ cli
746
763
  process.exit(EXIT_NO_RESTART);
747
764
  }
748
765
  });
749
- // Magic prefix used to identify bot-initiated sessions.
750
- // The running bot will recognize this prefix and start a session.
751
- const BOT_SESSION_PREFIX = '๐Ÿค– **Bot-initiated session**';
752
- // Notify-only prefix - bot won't start a session, just creates thread for notifications.
753
- // Reply to the thread to start a session with the notification as context.
754
- const BOT_NOTIFY_PREFIX = '๐Ÿ“ข **Notification**';
755
766
  cli
756
767
  .command('send', 'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.')
757
768
  .alias('start-session') // backwards compatibility
@@ -953,9 +964,7 @@ cli
953
964
  throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`);
954
965
  }
955
966
  s.message('Creating starter message...');
956
- // Create starter message with magic prefix
957
- // BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
958
- const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX;
967
+ // Create starter message with just the prompt (no prefix)
959
968
  const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
960
969
  method: 'POST',
961
970
  headers: {
@@ -963,7 +972,7 @@ cli
963
972
  'Content-Type': 'application/json',
964
973
  },
965
974
  body: JSON.stringify({
966
- content: `${messagePrefix}\n${prompt}`,
975
+ content: prompt,
967
976
  }),
968
977
  });
969
978
  if (!starterMessageResponse.ok) {
@@ -992,6 +1001,17 @@ cli
992
1001
  throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
993
1002
  }
994
1003
  const threadData = (await threadResponse.json());
1004
+ // Mark thread for auto-start if not notify-only
1005
+ // This is optional - only works if local database exists (for local bot auto-start)
1006
+ if (!notifyOnly) {
1007
+ try {
1008
+ const db = getDatabase();
1009
+ db.prepare('INSERT OR REPLACE INTO pending_auto_start (thread_id) VALUES (?)').run(threadData.id);
1010
+ }
1011
+ catch {
1012
+ // Database not available (e.g., CI environment) - skip auto-start marking
1013
+ }
1014
+ }
995
1015
  s.stop('Thread created!');
996
1016
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
997
1017
  const successMessage = notifyOnly
@@ -1,19 +1,29 @@
1
1
  // /agent command - Set the preferred agent for this channel or session.
2
+ // Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
2
3
  import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, } from 'discord.js';
3
4
  import crypto from 'node:crypto';
4
- import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js';
5
+ import { getDatabase, setChannelAgent, setSessionAgent, clearSessionModel, runModelMigrations } from '../database.js';
5
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
7
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
8
  import { createLogger } from '../logger.js';
8
9
  const agentLogger = createLogger('AGENT');
9
10
  const pendingAgentContexts = new Map();
10
- export async function handleAgentCommand({ interaction, appId, }) {
11
- await interaction.deferReply({ ephemeral: true });
12
- runModelMigrations();
11
+ /**
12
+ * Sanitize an agent name to be a valid Discord command name component.
13
+ * Lowercase, alphanumeric and hyphens only.
14
+ */
15
+ export function sanitizeAgentName(name) {
16
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
17
+ }
18
+ /**
19
+ * Resolve the context for an agent command (directory, channel, session).
20
+ * Returns null if the command cannot be executed in this context.
21
+ */
22
+ export async function resolveAgentCommandContext({ interaction, appId, }) {
13
23
  const channel = interaction.channel;
14
24
  if (!channel) {
15
25
  await interaction.editReply({ content: 'This command can only be used in a channel' });
16
- return;
26
+ return null;
17
27
  }
18
28
  const isThread = [
19
29
  ChannelType.PublicThread,
@@ -47,22 +57,53 @@ export async function handleAgentCommand({ interaction, appId, }) {
47
57
  await interaction.editReply({
48
58
  content: 'This command can only be used in text channels or threads',
49
59
  });
50
- return;
60
+ return null;
51
61
  }
52
62
  if (channelAppId && channelAppId !== appId) {
53
63
  await interaction.editReply({ content: 'This channel is not configured for this bot' });
54
- return;
64
+ return null;
55
65
  }
56
66
  if (!projectDirectory) {
57
67
  await interaction.editReply({
58
68
  content: 'This channel is not configured with a project directory',
59
69
  });
70
+ return null;
71
+ }
72
+ return {
73
+ dir: projectDirectory,
74
+ channelId: targetChannelId,
75
+ sessionId,
76
+ isThread,
77
+ };
78
+ }
79
+ /**
80
+ * Set the agent preference for a context (session or channel).
81
+ * When switching agents for a session, also clears the session model preference
82
+ * so the new agent's model takes effect.
83
+ */
84
+ export function setAgentForContext({ context, agentName, }) {
85
+ if (context.isThread && context.sessionId) {
86
+ setSessionAgent(context.sessionId, agentName);
87
+ // Clear session model so the new agent's model takes effect
88
+ clearSessionModel(context.sessionId);
89
+ agentLogger.log(`Set agent ${agentName} for session ${context.sessionId} (cleared model preference)`);
90
+ }
91
+ else {
92
+ setChannelAgent(context.channelId, agentName);
93
+ agentLogger.log(`Set agent ${agentName} for channel ${context.channelId}`);
94
+ }
95
+ }
96
+ export async function handleAgentCommand({ interaction, appId, }) {
97
+ await interaction.deferReply({ ephemeral: true });
98
+ runModelMigrations();
99
+ const context = await resolveAgentCommandContext({ interaction, appId });
100
+ if (!context) {
60
101
  return;
61
102
  }
62
103
  try {
63
- const getClient = await initializeOpencodeForDirectory(projectDirectory);
104
+ const getClient = await initializeOpencodeForDirectory(context.dir);
64
105
  const agentsResponse = await getClient().app.agents({
65
- query: { directory: projectDirectory },
106
+ query: { directory: context.dir },
66
107
  });
67
108
  if (!agentsResponse.data || agentsResponse.data.length === 0) {
68
109
  await interaction.editReply({ content: 'No agents available' });
@@ -76,12 +117,7 @@ export async function handleAgentCommand({ interaction, appId, }) {
76
117
  return;
77
118
  }
78
119
  const contextHash = crypto.randomBytes(8).toString('hex');
79
- pendingAgentContexts.set(contextHash, {
80
- dir: projectDirectory,
81
- channelId: targetChannelId,
82
- sessionId,
83
- isThread,
84
- });
120
+ pendingAgentContexts.set(contextHash, context);
85
121
  const options = agents.map((agent) => ({
86
122
  label: agent.name.slice(0, 100),
87
123
  value: agent.name,
@@ -128,17 +164,14 @@ export async function handleAgentSelectMenu(interaction) {
128
164
  return;
129
165
  }
130
166
  try {
167
+ setAgentForContext({ context, agentName: selectedAgent });
131
168
  if (context.isThread && context.sessionId) {
132
- setSessionAgent(context.sessionId, selectedAgent);
133
- agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`);
134
169
  await interaction.editReply({
135
170
  content: `Agent preference set for this session: **${selectedAgent}**`,
136
171
  components: [],
137
172
  });
138
173
  }
139
174
  else {
140
- setChannelAgent(context.channelId, selectedAgent);
141
- agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`);
142
175
  await interaction.editReply({
143
176
  content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
144
177
  components: [],
@@ -154,3 +187,52 @@ export async function handleAgentSelectMenu(interaction) {
154
187
  });
155
188
  }
156
189
  }
190
+ /**
191
+ * Handle quick agent commands like /plan-agent, /build-agent.
192
+ * These instantly switch to the specified agent without showing a dropdown.
193
+ */
194
+ export async function handleQuickAgentCommand({ command, appId, }) {
195
+ await command.deferReply({ ephemeral: true });
196
+ runModelMigrations();
197
+ // Extract agent name from command: "plan-agent" โ†’ "plan"
198
+ const sanitizedAgentName = command.commandName.replace(/-agent$/, '');
199
+ const context = await resolveAgentCommandContext({ interaction: command, appId });
200
+ if (!context) {
201
+ return;
202
+ }
203
+ try {
204
+ const getClient = await initializeOpencodeForDirectory(context.dir);
205
+ const agentsResponse = await getClient().app.agents({
206
+ query: { directory: context.dir },
207
+ });
208
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
209
+ await command.editReply({ content: 'No agents available in this project' });
210
+ return;
211
+ }
212
+ // Find the agent matching the sanitized command name
213
+ const matchingAgent = agentsResponse.data.find((a) => sanitizeAgentName(a.name) === sanitizedAgentName);
214
+ if (!matchingAgent) {
215
+ await command.editReply({
216
+ content: `Agent not found. Available agents: ${agentsResponse.data.map((a) => a.name).join(', ')}`,
217
+ });
218
+ return;
219
+ }
220
+ setAgentForContext({ context, agentName: matchingAgent.name });
221
+ if (context.isThread && context.sessionId) {
222
+ await command.editReply({
223
+ content: `Switched to **${matchingAgent.name}** agent for this session`,
224
+ });
225
+ }
226
+ else {
227
+ await command.editReply({
228
+ content: `Switched to **${matchingAgent.name}** agent for this channel\n\nAll new sessions will use this agent.`,
229
+ });
230
+ }
231
+ }
232
+ catch (error) {
233
+ agentLogger.error('Error in quick agent command:', error);
234
+ await command.editReply({
235
+ content: `Failed to switch agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
236
+ });
237
+ }
238
+ }
package/dist/database.js CHANGED
@@ -57,6 +57,13 @@ export function getDatabase() {
57
57
  catch {
58
58
  // Column already exists, ignore
59
59
  }
60
+ // Table for threads that should auto-start a session (created by CLI without --notify-only)
61
+ db.exec(`
62
+ CREATE TABLE IF NOT EXISTS pending_auto_start (
63
+ thread_id TEXT PRIMARY KEY,
64
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
65
+ )
66
+ `);
60
67
  db.exec(`
61
68
  CREATE TABLE IF NOT EXISTS bot_api_keys (
62
69
  app_id TEXT PRIMARY KEY,
@@ -147,6 +154,14 @@ export function setSessionModel(sessionId, modelId) {
147
154
  const db = getDatabase();
148
155
  db.prepare(`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`).run(sessionId, modelId);
149
156
  }
157
+ /**
158
+ * Clear the model preference for a session.
159
+ * Used when switching agents so the agent's model takes effect.
160
+ */
161
+ export function clearSessionModel(sessionId) {
162
+ const db = getDatabase();
163
+ db.prepare('DELETE FROM session_models WHERE session_id = ?').run(sessionId);
164
+ }
150
165
  /**
151
166
  * Get the agent preference for a channel.
152
167
  */
@@ -152,17 +152,11 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
152
152
  discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`);
153
153
  return;
154
154
  }
155
- // Include starter message (notification) as context for the session
155
+ // Include starter message as context for the session
156
156
  let prompt = message.content;
157
157
  const starterMessage = await thread.fetchStarterMessage().catch(() => null);
158
- if (starterMessage?.content) {
159
- // Strip notification prefix if present
160
- const notificationContent = starterMessage.content
161
- .replace(/^๐Ÿ“ข \*\*Notification\*\*\n?/, '')
162
- .trim();
163
- if (notificationContent) {
164
- prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`;
165
- }
158
+ if (starterMessage?.content && starterMessage.content !== message.content) {
159
+ prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`;
166
160
  }
167
161
  await handleOpencodeSession({
168
162
  prompt,
@@ -316,35 +310,35 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
316
310
  }
317
311
  }
318
312
  });
319
- // Magic prefix used by `kimaki send` CLI command to initiate sessions
320
- const BOT_SESSION_PREFIX = '๐Ÿค– **Bot-initiated session**';
321
- // Handle bot-initiated threads created by `kimaki send`
313
+ // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
322
314
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
323
315
  try {
324
316
  if (!newlyCreated) {
325
317
  return;
326
318
  }
319
+ // Check if this thread is marked for auto-start in the database
320
+ const db = getDatabase();
321
+ const pendingRow = db
322
+ .prepare('SELECT thread_id FROM pending_auto_start WHERE thread_id = ?')
323
+ .get(thread.id);
324
+ if (!pendingRow) {
325
+ return; // Not a CLI-initiated auto-start thread
326
+ }
327
+ // Remove from pending table
328
+ db.prepare('DELETE FROM pending_auto_start WHERE thread_id = ?').run(thread.id);
329
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
327
330
  // Only handle threads in text channels
328
331
  const parent = thread.parent;
329
332
  if (!parent || parent.type !== ChannelType.GuildText) {
330
333
  return;
331
334
  }
332
- // Get the starter message to check for magic prefix
335
+ // Get the starter message for the prompt
333
336
  const starterMessage = await thread.fetchStarterMessage().catch(() => null);
334
337
  if (!starterMessage) {
335
338
  discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
336
339
  return;
337
340
  }
338
- // Only handle messages from this bot with the magic prefix
339
- if (starterMessage.author.id !== discordClient.user?.id) {
340
- return;
341
- }
342
- if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
343
- return;
344
- }
345
- discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
346
- // Extract the prompt (everything after the prefix)
347
- const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim();
341
+ const prompt = starterMessage.content.trim();
348
342
  if (!prompt) {
349
343
  discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
350
344
  return;
@@ -12,7 +12,7 @@ import { handleAbortCommand } from './commands/abort.js';
12
12
  import { handleShareCommand } from './commands/share.js';
13
13
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
14
14
  import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, } from './commands/model.js';
15
- import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js';
15
+ import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand } from './commands/agent.js';
16
16
  import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
17
17
  import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js';
18
18
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
@@ -94,6 +94,11 @@ export function registerInteractionHandler({ discordClient, appId, }) {
94
94
  await handleRedoCommand({ command: interaction, appId });
95
95
  return;
96
96
  }
97
+ // Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
98
+ if (interaction.commandName.endsWith('-agent') && interaction.commandName !== 'agent') {
99
+ await handleQuickAgentCommand({ command: interaction, appId });
100
+ return;
101
+ }
97
102
  // Handle user-defined commands (ending with -cmd suffix)
98
103
  if (interaction.commandName.endsWith('-cmd')) {
99
104
  await handleUserCommand({ command: interaction, appId });
@@ -473,6 +473,30 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
473
473
  requestId: questionRequest.id,
474
474
  input: { questions: questionRequest.questions },
475
475
  });
476
+ // Process queued messages if any - queued message will cancel the pending question
477
+ const queue = messageQueue.get(thread.id);
478
+ if (queue && queue.length > 0) {
479
+ const nextMessage = queue.shift();
480
+ if (queue.length === 0) {
481
+ messageQueue.delete(thread.id);
482
+ }
483
+ sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
484
+ await sendThreadMessage(thread, `ยป **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
485
+ // handleOpencodeSession will call cancelPendingQuestion, which cancels the dropdown
486
+ setImmediate(() => {
487
+ handleOpencodeSession({
488
+ prompt: nextMessage.prompt,
489
+ thread,
490
+ projectDirectory: directory,
491
+ images: nextMessage.images,
492
+ channelId,
493
+ }).catch(async (e) => {
494
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, e);
495
+ const errorMsg = e instanceof Error ? e.message : String(e);
496
+ await sendThreadMessage(thread, `โœ— Queued message failed: ${errorMsg.slice(0, 200)}`);
497
+ });
498
+ });
499
+ }
476
500
  }
477
501
  else if (event.type === 'session.idle') {
478
502
  // Session is done processing - abort to signal completion
@@ -581,9 +605,20 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
581
605
  })();
582
606
  const parts = [{ type: 'text', text: promptWithImagePaths }, ...images];
583
607
  sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
608
+ // Get agent preference: session-level overrides channel-level
609
+ const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined);
610
+ if (agentPreference) {
611
+ sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
612
+ }
584
613
  // Get model preference: session-level overrides channel-level
614
+ // BUT: if an agent is set, don't pass model param so the agent's model takes effect
585
615
  const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
586
616
  const modelParam = (() => {
617
+ // When an agent is set, let the agent's model config take effect
618
+ if (agentPreference) {
619
+ sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`);
620
+ return undefined;
621
+ }
587
622
  if (!modelPreference) {
588
623
  return undefined;
589
624
  }
@@ -595,11 +630,6 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
595
630
  sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
596
631
  return { providerID, modelID };
597
632
  })();
598
- // Get agent preference: session-level overrides channel-level
599
- const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined);
600
- if (agentPreference) {
601
- sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
602
- }
603
633
  // Use session.command API for slash commands, session.prompt for regular messages
604
634
  const response = command
605
635
  ? await getClient().session.command({
@@ -650,8 +680,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
650
680
  return { sessionID: session.id, result: response.data, port };
651
681
  }
652
682
  catch (error) {
653
- sessionLogger.error(`ERROR: Failed to send prompt:`, error);
654
683
  if (!isAbortError(error, abortController.signal)) {
684
+ sessionLogger.error(`ERROR: Failed to send prompt:`, error);
655
685
  abortController.abort('error');
656
686
  if (originalMessage) {
657
687
  try {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.37",
5
+ "version": "0.4.38",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
@@ -11,7 +11,9 @@ export async function ensureKimakiCategory(
11
11
  guild: Guild,
12
12
  botName?: string,
13
13
  ): Promise<CategoryChannel> {
14
- const categoryName = botName ? `Kimaki ${botName}` : 'Kimaki'
14
+ // Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
15
+ const isKimakiBot = botName?.toLowerCase() === 'kimaki'
16
+ const categoryName = botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki'
15
17
 
16
18
  const existingCategory = guild.channels.cache.find((channel): channel is CategoryChannel => {
17
19
  if (channel.type !== ChannelType.GuildCategory) {
@@ -35,7 +37,9 @@ export async function ensureKimakiAudioCategory(
35
37
  guild: Guild,
36
38
  botName?: string,
37
39
  ): Promise<CategoryChannel> {
38
- const categoryName = botName ? `Kimaki Audio ${botName}` : 'Kimaki Audio'
40
+ // Skip appending bot name if it's already "kimaki" to avoid "Kimaki Audio kimaki"
41
+ const isKimakiBot = botName?.toLowerCase() === 'kimaki'
42
+ const categoryName = botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio'
39
43
 
40
44
  const existingCategory = guild.channels.cache.find((channel): channel is CategoryChannel => {
41
45
  if (channel.type !== ChannelType.GuildCategory) {
package/src/cli.ts CHANGED
@@ -46,6 +46,7 @@ import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_pro
46
46
  import http from 'node:http'
47
47
  import { setDataDir, getDataDir, getLockPort } from './config.js'
48
48
  import { extractTagsArrays } from './xml.js'
49
+ import { sanitizeAgentName } from './commands/agent.js'
49
50
 
50
51
  const cliLogger = createLogger('CLI')
51
52
  const cli = cac('kimaki')
@@ -176,11 +177,23 @@ type CliOptions = {
176
177
  // Commands to skip when registering user commands (reserved names)
177
178
  const SKIP_USER_COMMANDS = ['init']
178
179
 
179
- async function registerCommands(
180
- token: string,
181
- appId: string,
182
- userCommands: OpencodeCommand[] = [],
183
- ) {
180
+ type AgentInfo = {
181
+ name: string
182
+ description?: string
183
+ mode: string
184
+ }
185
+
186
+ async function registerCommands({
187
+ token,
188
+ appId,
189
+ userCommands = [],
190
+ agents = [],
191
+ }: {
192
+ token: string
193
+ appId: string
194
+ userCommands?: OpencodeCommand[]
195
+ agents?: AgentInfo[]
196
+ }) {
184
197
  const commands = [
185
198
  new SlashCommandBuilder()
186
199
  .setName('resume')
@@ -329,6 +342,22 @@ async function registerCommands(
329
342
  )
330
343
  }
331
344
 
345
+ // Add agent-specific quick commands like /plan-agent, /build-agent
346
+ // Filter to primary/all mode agents (same as /agent command shows)
347
+ const primaryAgents = agents.filter((a) => a.mode === 'primary' || a.mode === 'all')
348
+ for (const agent of primaryAgents) {
349
+ const sanitizedName = sanitizeAgentName(agent.name)
350
+ const commandName = `${sanitizedName}-agent`
351
+ const description = agent.description || `Switch to ${agent.name} agent`
352
+
353
+ commands.push(
354
+ new SlashCommandBuilder()
355
+ .setName(commandName.slice(0, 32)) // Discord limits to 32 chars
356
+ .setDescription(description.slice(0, 100))
357
+ .toJSON(),
358
+ )
359
+ }
360
+
332
361
  const rest = new REST().setToken(token)
333
362
 
334
363
  try {
@@ -669,8 +698,8 @@ async function run({ restart, addChannels }: CliOptions) {
669
698
 
670
699
  s.start('Fetching OpenCode data...')
671
700
 
672
- // Fetch projects and commands in parallel
673
- const [projects, allUserCommands] = await Promise.all([
701
+ // Fetch projects, commands, and agents in parallel
702
+ const [projects, allUserCommands, allAgents] = await Promise.all([
674
703
  getClient()
675
704
  .project.list({})
676
705
  .then((r) => r.data || [])
@@ -684,6 +713,10 @@ async function run({ restart, addChannels }: CliOptions) {
684
713
  .command.list({ query: { directory: currentDir } })
685
714
  .then((r) => r.data || [])
686
715
  .catch(() => []),
716
+ getClient()
717
+ .app.agents({ query: { directory: currentDir } })
718
+ .then((r) => r.data || [])
719
+ .catch(() => []),
687
720
  ])
688
721
 
689
722
  s.stop(`Found ${projects.length} OpenCode project(s)`)
@@ -805,7 +838,7 @@ async function run({ restart, addChannels }: CliOptions) {
805
838
  }
806
839
 
807
840
  cliLogger.log('Registering slash commands asynchronously...')
808
- void registerCommands(token, appId, allUserCommands)
841
+ void registerCommands({ token, appId, userCommands: allUserCommands, agents: allAgents })
809
842
  .then(() => {
810
843
  cliLogger.log('Slash commands registered!')
811
844
  })
@@ -999,12 +1032,7 @@ cli
999
1032
  }
1000
1033
  })
1001
1034
 
1002
- // Magic prefix used to identify bot-initiated sessions.
1003
- // The running bot will recognize this prefix and start a session.
1004
- const BOT_SESSION_PREFIX = '๐Ÿค– **Bot-initiated session**'
1005
- // Notify-only prefix - bot won't start a session, just creates thread for notifications.
1006
- // Reply to the thread to start a session with the notification as context.
1007
- const BOT_NOTIFY_PREFIX = '๐Ÿ“ข **Notification**'
1035
+
1008
1036
 
1009
1037
  cli
1010
1038
  .command(
@@ -1263,9 +1291,7 @@ cli
1263
1291
 
1264
1292
  s.message('Creating starter message...')
1265
1293
 
1266
- // Create starter message with magic prefix
1267
- // BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
1268
- const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX
1294
+ // Create starter message with just the prompt (no prefix)
1269
1295
  const starterMessageResponse = await fetch(
1270
1296
  `https://discord.com/api/v10/channels/${channelId}/messages`,
1271
1297
  {
@@ -1275,7 +1301,7 @@ cli
1275
1301
  'Content-Type': 'application/json',
1276
1302
  },
1277
1303
  body: JSON.stringify({
1278
- content: `${messagePrefix}\n${prompt}`,
1304
+ content: prompt,
1279
1305
  }),
1280
1306
  },
1281
1307
  )
@@ -1315,6 +1341,19 @@ cli
1315
1341
 
1316
1342
  const threadData = (await threadResponse.json()) as { id: string; name: string }
1317
1343
 
1344
+ // Mark thread for auto-start if not notify-only
1345
+ // This is optional - only works if local database exists (for local bot auto-start)
1346
+ if (!notifyOnly) {
1347
+ try {
1348
+ const db = getDatabase()
1349
+ db.prepare('INSERT OR REPLACE INTO pending_auto_start (thread_id) VALUES (?)').run(
1350
+ threadData.id,
1351
+ )
1352
+ } catch {
1353
+ // Database not available (e.g., CI environment) - skip auto-start marking
1354
+ }
1355
+ }
1356
+
1318
1357
  s.stop('Thread created!')
1319
1358
 
1320
1359
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
@@ -1,4 +1,5 @@
1
1
  // /agent command - Set the preferred agent for this channel or session.
2
+ // Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
2
3
 
3
4
  import {
4
5
  ChatInputCommandInteraction,
@@ -10,7 +11,7 @@ import {
10
11
  type TextChannel,
11
12
  } from 'discord.js'
12
13
  import crypto from 'node:crypto'
13
- import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js'
14
+ import { getDatabase, setChannelAgent, setSessionAgent, clearSessionModel, runModelMigrations } from '../database.js'
14
15
  import { initializeOpencodeForDirectory } from '../opencode.js'
15
16
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
16
17
  import { createLogger } from '../logger.js'
@@ -27,22 +28,40 @@ const pendingAgentContexts = new Map<
27
28
  }
28
29
  >()
29
30
 
30
- export async function handleAgentCommand({
31
+ /**
32
+ * Context for agent commands, containing channel/session info.
33
+ */
34
+ export type AgentCommandContext = {
35
+ dir: string
36
+ channelId: string
37
+ sessionId?: string
38
+ isThread: boolean
39
+ }
40
+
41
+ /**
42
+ * Sanitize an agent name to be a valid Discord command name component.
43
+ * Lowercase, alphanumeric and hyphens only.
44
+ */
45
+ export function sanitizeAgentName(name: string): string {
46
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
47
+ }
48
+
49
+ /**
50
+ * Resolve the context for an agent command (directory, channel, session).
51
+ * Returns null if the command cannot be executed in this context.
52
+ */
53
+ export async function resolveAgentCommandContext({
31
54
  interaction,
32
55
  appId,
33
56
  }: {
34
57
  interaction: ChatInputCommandInteraction
35
58
  appId: string
36
- }): Promise<void> {
37
- await interaction.deferReply({ ephemeral: true })
38
-
39
- runModelMigrations()
40
-
59
+ }): Promise<AgentCommandContext | null> {
41
60
  const channel = interaction.channel
42
61
 
43
62
  if (!channel) {
44
63
  await interaction.editReply({ content: 'This command can only be used in a channel' })
45
- return
64
+ return null
46
65
  }
47
66
 
48
67
  const isThread = [
@@ -78,26 +97,73 @@ export async function handleAgentCommand({
78
97
  await interaction.editReply({
79
98
  content: 'This command can only be used in text channels or threads',
80
99
  })
81
- return
100
+ return null
82
101
  }
83
102
 
84
103
  if (channelAppId && channelAppId !== appId) {
85
104
  await interaction.editReply({ content: 'This channel is not configured for this bot' })
86
- return
105
+ return null
87
106
  }
88
107
 
89
108
  if (!projectDirectory) {
90
109
  await interaction.editReply({
91
110
  content: 'This channel is not configured with a project directory',
92
111
  })
112
+ return null
113
+ }
114
+
115
+ return {
116
+ dir: projectDirectory,
117
+ channelId: targetChannelId,
118
+ sessionId,
119
+ isThread,
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Set the agent preference for a context (session or channel).
125
+ * When switching agents for a session, also clears the session model preference
126
+ * so the new agent's model takes effect.
127
+ */
128
+ export function setAgentForContext({
129
+ context,
130
+ agentName,
131
+ }: {
132
+ context: AgentCommandContext
133
+ agentName: string
134
+ }): void {
135
+ if (context.isThread && context.sessionId) {
136
+ setSessionAgent(context.sessionId, agentName)
137
+ // Clear session model so the new agent's model takes effect
138
+ clearSessionModel(context.sessionId)
139
+ agentLogger.log(`Set agent ${agentName} for session ${context.sessionId} (cleared model preference)`)
140
+ } else {
141
+ setChannelAgent(context.channelId, agentName)
142
+ agentLogger.log(`Set agent ${agentName} for channel ${context.channelId}`)
143
+ }
144
+ }
145
+
146
+ export async function handleAgentCommand({
147
+ interaction,
148
+ appId,
149
+ }: {
150
+ interaction: ChatInputCommandInteraction
151
+ appId: string
152
+ }): Promise<void> {
153
+ await interaction.deferReply({ ephemeral: true })
154
+
155
+ runModelMigrations()
156
+
157
+ const context = await resolveAgentCommandContext({ interaction, appId })
158
+ if (!context) {
93
159
  return
94
160
  }
95
161
 
96
162
  try {
97
- const getClient = await initializeOpencodeForDirectory(projectDirectory)
163
+ const getClient = await initializeOpencodeForDirectory(context.dir)
98
164
 
99
165
  const agentsResponse = await getClient().app.agents({
100
- query: { directory: projectDirectory },
166
+ query: { directory: context.dir },
101
167
  })
102
168
 
103
169
  if (!agentsResponse.data || agentsResponse.data.length === 0) {
@@ -115,12 +181,7 @@ export async function handleAgentCommand({
115
181
  }
116
182
 
117
183
  const contextHash = crypto.randomBytes(8).toString('hex')
118
- pendingAgentContexts.set(contextHash, {
119
- dir: projectDirectory,
120
- channelId: targetChannelId,
121
- sessionId,
122
- isThread,
123
- })
184
+ pendingAgentContexts.set(contextHash, context)
124
185
 
125
186
  const options = agents.map((agent) => ({
126
187
  label: agent.name.slice(0, 100),
@@ -179,18 +240,14 @@ export async function handleAgentSelectMenu(
179
240
  }
180
241
 
181
242
  try {
182
- if (context.isThread && context.sessionId) {
183
- setSessionAgent(context.sessionId, selectedAgent)
184
- agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`)
243
+ setAgentForContext({ context, agentName: selectedAgent })
185
244
 
245
+ if (context.isThread && context.sessionId) {
186
246
  await interaction.editReply({
187
247
  content: `Agent preference set for this session: **${selectedAgent}**`,
188
248
  components: [],
189
249
  })
190
250
  } else {
191
- setChannelAgent(context.channelId, selectedAgent)
192
- agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`)
193
-
194
251
  await interaction.editReply({
195
252
  content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
196
253
  components: [],
@@ -206,3 +263,69 @@ export async function handleAgentSelectMenu(
206
263
  })
207
264
  }
208
265
  }
266
+
267
+ /**
268
+ * Handle quick agent commands like /plan-agent, /build-agent.
269
+ * These instantly switch to the specified agent without showing a dropdown.
270
+ */
271
+ export async function handleQuickAgentCommand({
272
+ command,
273
+ appId,
274
+ }: {
275
+ command: ChatInputCommandInteraction
276
+ appId: string
277
+ }): Promise<void> {
278
+ await command.deferReply({ ephemeral: true })
279
+
280
+ runModelMigrations()
281
+
282
+ // Extract agent name from command: "plan-agent" โ†’ "plan"
283
+ const sanitizedAgentName = command.commandName.replace(/-agent$/, '')
284
+
285
+ const context = await resolveAgentCommandContext({ interaction: command, appId })
286
+ if (!context) {
287
+ return
288
+ }
289
+
290
+ try {
291
+ const getClient = await initializeOpencodeForDirectory(context.dir)
292
+
293
+ const agentsResponse = await getClient().app.agents({
294
+ query: { directory: context.dir },
295
+ })
296
+
297
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
298
+ await command.editReply({ content: 'No agents available in this project' })
299
+ return
300
+ }
301
+
302
+ // Find the agent matching the sanitized command name
303
+ const matchingAgent = agentsResponse.data.find(
304
+ (a) => sanitizeAgentName(a.name) === sanitizedAgentName
305
+ )
306
+
307
+ if (!matchingAgent) {
308
+ await command.editReply({
309
+ content: `Agent not found. Available agents: ${agentsResponse.data.map((a) => a.name).join(', ')}`,
310
+ })
311
+ return
312
+ }
313
+
314
+ setAgentForContext({ context, agentName: matchingAgent.name })
315
+
316
+ if (context.isThread && context.sessionId) {
317
+ await command.editReply({
318
+ content: `Switched to **${matchingAgent.name}** agent for this session`,
319
+ })
320
+ } else {
321
+ await command.editReply({
322
+ content: `Switched to **${matchingAgent.name}** agent for this channel\n\nAll new sessions will use this agent.`,
323
+ })
324
+ }
325
+ } catch (error) {
326
+ agentLogger.error('Error in quick agent command:', error)
327
+ await command.editReply({
328
+ content: `Failed to switch agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
329
+ })
330
+ }
331
+ }
package/src/database.ts CHANGED
@@ -68,6 +68,14 @@ export function getDatabase(): Database.Database {
68
68
  // Column already exists, ignore
69
69
  }
70
70
 
71
+ // Table for threads that should auto-start a session (created by CLI without --notify-only)
72
+ db.exec(`
73
+ CREATE TABLE IF NOT EXISTS pending_auto_start (
74
+ thread_id TEXT PRIMARY KEY,
75
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
76
+ )
77
+ `)
78
+
71
79
  db.exec(`
72
80
  CREATE TABLE IF NOT EXISTS bot_api_keys (
73
81
  app_id TEXT PRIMARY KEY,
@@ -176,6 +184,15 @@ export function setSessionModel(sessionId: string, modelId: string): void {
176
184
  )
177
185
  }
178
186
 
187
+ /**
188
+ * Clear the model preference for a session.
189
+ * Used when switching agents so the agent's model takes effect.
190
+ */
191
+ export function clearSessionModel(sessionId: string): void {
192
+ const db = getDatabase()
193
+ db.prepare('DELETE FROM session_models WHERE session_id = ?').run(sessionId)
194
+ }
195
+
179
196
  /**
180
197
  * Get the agent preference for a channel.
181
198
  */
@@ -230,17 +230,11 @@ export async function startDiscordBot({
230
230
  return
231
231
  }
232
232
 
233
- // Include starter message (notification) as context for the session
233
+ // Include starter message as context for the session
234
234
  let prompt = message.content
235
235
  const starterMessage = await thread.fetchStarterMessage().catch(() => null)
236
- if (starterMessage?.content) {
237
- // Strip notification prefix if present
238
- const notificationContent = starterMessage.content
239
- .replace(/^๐Ÿ“ข \*\*Notification\*\*\n?/, '')
240
- .trim()
241
- if (notificationContent) {
242
- prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`
243
- }
236
+ if (starterMessage?.content && starterMessage.content !== message.content) {
237
+ prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`
244
238
  }
245
239
 
246
240
  await handleOpencodeSession({
@@ -419,42 +413,42 @@ export async function startDiscordBot({
419
413
  }
420
414
  })
421
415
 
422
- // Magic prefix used by `kimaki send` CLI command to initiate sessions
423
- const BOT_SESSION_PREFIX = '๐Ÿค– **Bot-initiated session**'
424
-
425
- // Handle bot-initiated threads created by `kimaki send`
416
+ // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
426
417
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
427
418
  try {
428
419
  if (!newlyCreated) {
429
420
  return
430
421
  }
431
422
 
423
+ // Check if this thread is marked for auto-start in the database
424
+ const db = getDatabase()
425
+ const pendingRow = db
426
+ .prepare('SELECT thread_id FROM pending_auto_start WHERE thread_id = ?')
427
+ .get(thread.id) as { thread_id: string } | undefined
428
+
429
+ if (!pendingRow) {
430
+ return // Not a CLI-initiated auto-start thread
431
+ }
432
+
433
+ // Remove from pending table
434
+ db.prepare('DELETE FROM pending_auto_start WHERE thread_id = ?').run(thread.id)
435
+
436
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
437
+
432
438
  // Only handle threads in text channels
433
439
  const parent = thread.parent as TextChannel | null
434
440
  if (!parent || parent.type !== ChannelType.GuildText) {
435
441
  return
436
442
  }
437
443
 
438
- // Get the starter message to check for magic prefix
444
+ // Get the starter message for the prompt
439
445
  const starterMessage = await thread.fetchStarterMessage().catch(() => null)
440
446
  if (!starterMessage) {
441
447
  discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
442
448
  return
443
449
  }
444
450
 
445
- // Only handle messages from this bot with the magic prefix
446
- if (starterMessage.author.id !== discordClient.user?.id) {
447
- return
448
- }
449
-
450
- if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
451
- return
452
- }
453
-
454
- discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
455
-
456
- // Extract the prompt (everything after the prefix)
457
- const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim()
451
+ const prompt = starterMessage.content.trim()
458
452
  if (!prompt) {
459
453
  discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
460
454
  return
@@ -20,7 +20,7 @@ import {
20
20
  handleProviderSelectMenu,
21
21
  handleModelSelectMenu,
22
22
  } from './commands/model.js'
23
- import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js'
23
+ import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand } from './commands/agent.js'
24
24
  import { handleAskQuestionSelectMenu } from './commands/ask-question.js'
25
25
  import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js'
26
26
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js'
@@ -136,6 +136,12 @@ export function registerInteractionHandler({
136
136
  return
137
137
  }
138
138
 
139
+ // Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
140
+ if (interaction.commandName.endsWith('-agent') && interaction.commandName !== 'agent') {
141
+ await handleQuickAgentCommand({ command: interaction, appId })
142
+ return
143
+ }
144
+
139
145
  // Handle user-defined commands (ending with -cmd suffix)
140
146
  if (interaction.commandName.endsWith('-cmd')) {
141
147
  await handleUserCommand({ command: interaction, appId })
@@ -641,6 +641,39 @@ export async function handleOpencodeSession({
641
641
  requestId: questionRequest.id,
642
642
  input: { questions: questionRequest.questions },
643
643
  })
644
+
645
+ // Process queued messages if any - queued message will cancel the pending question
646
+ const queue = messageQueue.get(thread.id)
647
+ if (queue && queue.length > 0) {
648
+ const nextMessage = queue.shift()!
649
+ if (queue.length === 0) {
650
+ messageQueue.delete(thread.id)
651
+ }
652
+
653
+ sessionLogger.log(
654
+ `[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`,
655
+ )
656
+
657
+ await sendThreadMessage(
658
+ thread,
659
+ `ยป **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`,
660
+ )
661
+
662
+ // handleOpencodeSession will call cancelPendingQuestion, which cancels the dropdown
663
+ setImmediate(() => {
664
+ handleOpencodeSession({
665
+ prompt: nextMessage.prompt,
666
+ thread,
667
+ projectDirectory: directory,
668
+ images: nextMessage.images,
669
+ channelId,
670
+ }).catch(async (e) => {
671
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, e)
672
+ const errorMsg = e instanceof Error ? e.message : String(e)
673
+ await sendThreadMessage(thread, `โœ— Queued message failed: ${errorMsg.slice(0, 200)}`)
674
+ })
675
+ })
676
+ }
644
677
  } else if (event.type === 'session.idle') {
645
678
  // Session is done processing - abort to signal completion
646
679
  if (event.properties.sessionID === session.id) {
@@ -774,10 +807,23 @@ export async function handleOpencodeSession({
774
807
  const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
775
808
  sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
776
809
 
810
+ // Get agent preference: session-level overrides channel-level
811
+ const agentPreference =
812
+ getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
813
+ if (agentPreference) {
814
+ sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
815
+ }
816
+
777
817
  // Get model preference: session-level overrides channel-level
818
+ // BUT: if an agent is set, don't pass model param so the agent's model takes effect
778
819
  const modelPreference =
779
820
  getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
780
821
  const modelParam = (() => {
822
+ // When an agent is set, let the agent's model config take effect
823
+ if (agentPreference) {
824
+ sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`)
825
+ return undefined
826
+ }
781
827
  if (!modelPreference) {
782
828
  return undefined
783
829
  }
@@ -790,13 +836,6 @@ export async function handleOpencodeSession({
790
836
  return { providerID, modelID }
791
837
  })()
792
838
 
793
- // Get agent preference: session-level overrides channel-level
794
- const agentPreference =
795
- getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
796
- if (agentPreference) {
797
- sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
798
- }
799
-
800
839
  // Use session.command API for slash commands, session.prompt for regular messages
801
840
  const response = command
802
841
  ? await getClient().session.command({
@@ -850,9 +889,8 @@ export async function handleOpencodeSession({
850
889
 
851
890
  return { sessionID: session.id, result: response.data, port }
852
891
  } catch (error) {
853
- sessionLogger.error(`ERROR: Failed to send prompt:`, error)
854
-
855
892
  if (!isAbortError(error, abortController.signal)) {
893
+ sessionLogger.error(`ERROR: Failed to send prompt:`, error)
856
894
  abortController.abort('error')
857
895
 
858
896
  if (originalMessage) {