kimaki 0.4.36 → 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.
@@ -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 {
@@ -28,7 +28,11 @@ ${channelId
28
28
 
29
29
  To start a new thread/session in this channel programmatically, run:
30
30
 
31
- npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
31
+ npx -y kimaki send --channel ${channelId} --prompt "your prompt here"
32
+
33
+ Use --notify-only to create a notification thread without starting an AI session:
34
+
35
+ npx -y kimaki send --channel ${channelId} --prompt "User cancelled subscription" --notify-only
32
36
 
33
37
  This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
34
38
  `
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.36",
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) {
@@ -90,15 +94,15 @@ export async function createProjectChannels({
90
94
 
91
95
  getDatabase()
92
96
  .prepare(
93
- 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
97
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
94
98
  )
95
- .run(textChannel.id, projectDirectory, 'text')
99
+ .run(textChannel.id, projectDirectory, 'text', appId)
96
100
 
97
101
  getDatabase()
98
102
  .prepare(
99
- 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
103
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
100
104
  )
101
- .run(voiceChannel.id, projectDirectory, 'voice')
105
+ .run(voiceChannel.id, projectDirectory, 'voice', appId)
102
106
 
103
107
  return {
104
108
  textChannelId: textChannel.id,
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 {
@@ -632,8 +661,8 @@ async function run({ restart, addChannels }: CliOptions) {
632
661
  for (const channel of channels) {
633
662
  if (channel.kimakiDirectory) {
634
663
  db.prepare(
635
- 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
636
- ).run(channel.id, channel.kimakiDirectory, 'text')
664
+ 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
665
+ ).run(channel.id, channel.kimakiDirectory, 'text', channel.kimakiApp || null)
637
666
 
638
667
  const voiceChannel = guild.channels.cache.find(
639
668
  (ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name,
@@ -641,8 +670,8 @@ async function run({ restart, addChannels }: CliOptions) {
641
670
 
642
671
  if (voiceChannel) {
643
672
  db.prepare(
644
- 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
645
- ).run(voiceChannel.id, channel.kimakiDirectory, 'voice')
673
+ 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
674
+ ).run(voiceChannel.id, channel.kimakiDirectory, 'voice', channel.kimakiApp || null)
646
675
  }
647
676
  }
648
677
  }
@@ -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,20 +1032,20 @@ 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**'
1035
+
1005
1036
 
1006
1037
  cli
1007
1038
  .command(
1008
- 'start-session',
1009
- 'Start a new session in a Discord channel (creates thread, bot handles the rest)',
1039
+ 'send',
1040
+ 'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.',
1010
1041
  )
1042
+ .alias('start-session') // backwards compatibility
1011
1043
  .option('-c, --channel <channelId>', 'Discord channel ID')
1012
1044
  .option('-d, --project <path>', 'Project directory (alternative to --channel)')
1013
- .option('-p, --prompt <prompt>', 'Initial prompt for the session')
1045
+ .option('-p, --prompt <prompt>', 'Message content')
1014
1046
  .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
1015
1047
  .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
1048
+ .option('--notify-only', 'Create notification thread without starting AI session')
1016
1049
  .action(
1017
1050
  async (options: {
1018
1051
  channel?: string
@@ -1020,10 +1053,20 @@ cli
1020
1053
  prompt?: string
1021
1054
  name?: string
1022
1055
  appId?: string
1056
+ notifyOnly?: boolean
1023
1057
  }) => {
1024
1058
  try {
1025
- let { channel: channelId, prompt, name, appId: optionAppId } = options
1059
+ let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly } = options
1026
1060
  const { project: projectPath } = options
1061
+
1062
+ // Get raw channel ID from argv to prevent JS number precision loss on large Discord IDs
1063
+ // cac parses large numbers and loses precision, so we extract the original string value
1064
+ if (channelId) {
1065
+ const channelArgIndex = process.argv.findIndex((arg) => arg === '--channel' || arg === '-c')
1066
+ if (channelArgIndex !== -1 && process.argv[channelArgIndex + 1]) {
1067
+ channelId = process.argv[channelArgIndex + 1]
1068
+ }
1069
+ }
1027
1070
 
1028
1071
  if (!channelId && !projectPath) {
1029
1072
  cliLogger.error('Either --channel or --project is required')
@@ -1092,18 +1135,43 @@ cli
1092
1135
 
1093
1136
  s.start('Looking up channel for project...')
1094
1137
 
1095
- // Check if channel already exists for this directory
1138
+ // Check if channel already exists for this directory or a parent directory
1139
+ // This allows running from subfolders of a registered project
1096
1140
  try {
1097
1141
  const db = getDatabase()
1098
- const existingChannel = db
1099
- .prepare(
1100
- 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
1101
- )
1102
- .get(absolutePath, 'text') as { channel_id: string } | undefined
1142
+
1143
+ // Helper to find channel for a path (prefers current bot's channel)
1144
+ const findChannelForPath = (dirPath: string): { channel_id: string; directory: string } | undefined => {
1145
+ const withAppId = db
1146
+ .prepare(
1147
+ 'SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?',
1148
+ )
1149
+ .get(dirPath, 'text', appId) as { channel_id: string; directory: string } | undefined
1150
+ if (withAppId) return withAppId
1151
+
1152
+ return db
1153
+ .prepare(
1154
+ 'SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ?',
1155
+ )
1156
+ .get(dirPath, 'text') as { channel_id: string; directory: string } | undefined
1157
+ }
1158
+
1159
+ // Try exact match first, then walk up parent directories
1160
+ let existingChannel: { channel_id: string; directory: string } | undefined
1161
+ let searchPath = absolutePath
1162
+ while (searchPath !== path.dirname(searchPath)) {
1163
+ existingChannel = findChannelForPath(searchPath)
1164
+ if (existingChannel) break
1165
+ searchPath = path.dirname(searchPath)
1166
+ }
1103
1167
 
1104
1168
  if (existingChannel) {
1105
1169
  channelId = existingChannel.channel_id
1106
- s.message(`Found existing channel: ${channelId}`)
1170
+ if (existingChannel.directory !== absolutePath) {
1171
+ s.message(`Found parent project channel: ${existingChannel.directory}`)
1172
+ } else {
1173
+ s.message(`Found existing channel: ${channelId}`)
1174
+ }
1107
1175
  } else {
1108
1176
  // Need to create a new channel
1109
1177
  s.message('Creating new channel...')
@@ -1128,12 +1196,12 @@ cli
1128
1196
 
1129
1197
  // Get guild from existing channels or first available
1130
1198
  const guild = await (async () => {
1131
- // Try to find a guild from existing channels
1199
+ // Try to find a guild from existing channels belonging to this bot
1132
1200
  const existingChannelRow = db
1133
1201
  .prepare(
1134
- 'SELECT channel_id FROM channel_directories ORDER BY created_at DESC LIMIT 1',
1202
+ 'SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1',
1135
1203
  )
1136
- .get() as { channel_id: string } | undefined
1204
+ .get(appId) as { channel_id: string } | undefined
1137
1205
 
1138
1206
  if (existingChannelRow) {
1139
1207
  try {
@@ -1145,7 +1213,7 @@ cli
1145
1213
  // Channel might be deleted, continue
1146
1214
  }
1147
1215
  }
1148
- // Fall back to first guild
1216
+ // Fall back to first guild the bot is in
1149
1217
  const firstGuild = client.guilds.cache.first()
1150
1218
  if (!firstGuild) {
1151
1219
  throw new Error('No guild found. Add the bot to a server first.')
@@ -1223,8 +1291,7 @@ cli
1223
1291
 
1224
1292
  s.message('Creating starter message...')
1225
1293
 
1226
- // Create starter message with magic prefix
1227
- // The full prompt goes in the message so the bot can read it
1294
+ // Create starter message with just the prompt (no prefix)
1228
1295
  const starterMessageResponse = await fetch(
1229
1296
  `https://discord.com/api/v10/channels/${channelId}/messages`,
1230
1297
  {
@@ -1234,7 +1301,7 @@ cli
1234
1301
  'Content-Type': 'application/json',
1235
1302
  },
1236
1303
  body: JSON.stringify({
1237
- content: `${BOT_SESSION_PREFIX}\n${prompt}`,
1304
+ content: prompt,
1238
1305
  }),
1239
1306
  },
1240
1307
  )
@@ -1274,14 +1341,28 @@ cli
1274
1341
 
1275
1342
  const threadData = (await threadResponse.json()) as { id: string; name: string }
1276
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
+
1277
1357
  s.stop('Thread created!')
1278
1358
 
1279
1359
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
1280
1360
 
1281
- note(
1282
- `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`,
1283
- '✅ Thread Created',
1284
- )
1361
+ const successMessage = notifyOnly
1362
+ ? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
1363
+ : `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`
1364
+
1365
+ note(successMessage, '✅ Thread Created')
1285
1366
 
1286
1367
  console.log(threadUrl)
1287
1368
 
@@ -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
+ }