kimaki 0.25.0 → 0.26.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 (70) hide show
  1. package/dist/agent-model.e2e.test.js +135 -3
  2. package/dist/analytics.test.js +30 -1
  3. package/dist/cli-commands/send.js +13 -3
  4. package/dist/cli-commands/task.js +26 -7
  5. package/dist/cli-runner.js +66 -70
  6. package/dist/cli-runner.test.js +10 -1
  7. package/dist/commands/agent.js +87 -74
  8. package/dist/commands/model.js +18 -0
  9. package/dist/commands/multioauth.js +169 -1
  10. package/dist/commands/tasks.js +6 -0
  11. package/dist/commands/unset-model.js +5 -20
  12. package/dist/database.js +65 -0
  13. package/dist/discord-bot.js +7 -1
  14. package/dist/ipc-tools-plugin.js +4 -3
  15. package/dist/kimaki-opencode-plugin.js +2 -0
  16. package/dist/message-formatting.js +84 -5
  17. package/dist/message-formatting.test.js +160 -2
  18. package/dist/oauth-rotation-shared.js +7 -1
  19. package/dist/openai-auth-state.js +20 -0
  20. package/dist/schema.js +19 -0
  21. package/dist/session-handler/event-stream-state.js +147 -0
  22. package/dist/session-handler/event-stream-state.test.js +437 -1
  23. package/dist/session-handler/thread-session-runtime.js +90 -37
  24. package/dist/subagent-rate-limit-plugin.js +13 -2
  25. package/dist/system-message.js +18 -4
  26. package/dist/system-message.test.js +28 -4
  27. package/dist/task-runner.js +143 -12
  28. package/dist/task-schedule.js +12 -0
  29. package/dist/task-schedule.test.js +149 -2
  30. package/dist/xai-auth-plugin.js +237 -0
  31. package/dist/xai-auth-state.js +233 -0
  32. package/dist/xai-auth-state.test.js +183 -0
  33. package/package.json +6 -6
  34. package/skills/profano/SKILL.md +5 -3
  35. package/skills/spiceflow/SKILL.md +24 -1
  36. package/src/agent-model.e2e.test.ts +172 -4
  37. package/src/analytics.test.ts +30 -1
  38. package/src/analytics.ts +1 -0
  39. package/src/cli-commands/send.ts +22 -3
  40. package/src/cli-commands/task.ts +32 -19
  41. package/src/cli-runner.test.ts +16 -1
  42. package/src/cli-runner.ts +88 -89
  43. package/src/commands/agent.ts +120 -84
  44. package/src/commands/model.ts +25 -0
  45. package/src/commands/multioauth.ts +195 -2
  46. package/src/commands/tasks.ts +6 -0
  47. package/src/commands/unset-model.ts +5 -21
  48. package/src/database.ts +124 -0
  49. package/src/discord-bot.ts +7 -1
  50. package/src/ipc-tools-plugin.ts +4 -3
  51. package/src/kimaki-opencode-plugin.ts +2 -0
  52. package/src/message-formatting.test.ts +197 -2
  53. package/src/message-formatting.ts +98 -5
  54. package/src/oauth-rotation-shared.ts +6 -1
  55. package/src/openai-auth-state.ts +19 -0
  56. package/src/schema.sql +15 -0
  57. package/src/schema.ts +20 -0
  58. package/src/session-handler/event-stream-state.test.ts +490 -0
  59. package/src/session-handler/event-stream-state.ts +210 -0
  60. package/src/session-handler/model-utils.ts +1 -0
  61. package/src/session-handler/thread-session-runtime.ts +105 -44
  62. package/src/subagent-rate-limit-plugin.ts +12 -2
  63. package/src/system-message.test.ts +29 -4
  64. package/src/system-message.ts +20 -4
  65. package/src/task-runner.ts +168 -11
  66. package/src/task-schedule.test.ts +172 -2
  67. package/src/task-schedule.ts +23 -2
  68. package/src/xai-auth-plugin.ts +302 -0
  69. package/src/xai-auth-state.test.ts +224 -0
  70. package/src/xai-auth-state.ts +293 -0
package/src/cli-runner.ts CHANGED
@@ -57,7 +57,6 @@ import { discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl, getInt
57
57
  import crypto from 'node:crypto'
58
58
  import path from 'node:path'
59
59
  import fs from 'node:fs'
60
- import os from 'node:os'
61
60
  import { spawn } from 'node:child_process'
62
61
  import { createLogger, LogPrefix } from './logger.js'
63
62
  import { notifyError } from './sentry.js'
@@ -206,6 +205,44 @@ export async function resolveBotCredentials({ appIdOverride }: { appIdOverride?:
206
205
 
207
206
  export { isThreadChannelType }
208
207
 
208
+ /** Wrap long lines so prompt.md is readable in Discord's attachment preview. */
209
+ function wrapPromptAttachmentText(prompt: string): string {
210
+ return prompt
211
+ .split('\n')
212
+ .flatMap((line) => {
213
+ if (line.length <= 120) {
214
+ return [line]
215
+ }
216
+ const wrapped: string[] = []
217
+ let remaining = line
218
+ const maxCol = 120
219
+ // Only soft-break at a space if it's reasonably close to maxCol,
220
+ // otherwise hard-break to avoid tiny fragments from early spaces
221
+ const minSoftBreak = 90
222
+ while (remaining.length > maxCol) {
223
+ const lastSpace = remaining.lastIndexOf(' ', maxCol)
224
+ const useSoftBreak = lastSpace >= minSoftBreak
225
+ const breakAt = useSoftBreak ? lastSpace : maxCol
226
+ wrapped.push(remaining.slice(0, breakAt))
227
+ // Only consume the separator space on soft breaks
228
+ remaining = useSoftBreak
229
+ ? remaining.slice(breakAt + 1)
230
+ : remaining.slice(breakAt)
231
+ }
232
+ if (remaining.length > 0) {
233
+ wrapped.push(remaining)
234
+ }
235
+ return wrapped
236
+ })
237
+ .join('\n')
238
+ }
239
+
240
+ function promptAttachmentBlob(text: string) {
241
+ return new Blob([new Uint8Array(Buffer.from(text, 'utf8'))], {
242
+ type: 'text/markdown',
243
+ })
244
+ }
245
+
209
246
  export async function sendDiscordMessageWithOptionalAttachment({
210
247
  channelId,
211
248
  prompt,
@@ -219,7 +256,8 @@ export async function sendDiscordMessageWithOptionalAttachment({
219
256
  prompt: string
220
257
  botToken: string
221
258
  embeds?: Array<{ color: number; footer: { text: string } }>
222
- rest: REST
259
+ /** Only `post` is used (short prompt / split paths). Accept a narrow shape so tests need no cast. */
260
+ rest: Pick<REST, 'post'>
223
261
  /** When true, long messages are split into multiple Discord messages instead of
224
262
  * being attached as a file. Useful for notify-only messages where the content
225
263
  * should be directly visible in the channel. */
@@ -246,24 +284,26 @@ export async function sendDiscordMessageWithOptionalAttachment({
246
284
  }
247
285
 
248
286
  // When prompt exceeds Discord's limit, attach it as prompt.md alongside
249
- // user files so nothing is silently lost.
287
+ // user files so nothing is silently lost. Build prompt.md from memory so
288
+ // parallel kimaki send processes never share or unlink a temp path.
250
289
  const isLongPrompt = prompt.length > discordMaxLength
251
290
  const content = isLongPrompt
252
291
  ? `Prompt attached as file (${prompt.length} chars)\n\n> ${prompt.slice(0, 100).replace(/\n/g, ' ')}...`
253
292
  : prompt
254
293
 
255
- // Build attachment metadata: user files + optional prompt.md
256
- const allFiles: Array<{ filePath: string; filename: string; mimeType: string }> = files.map((file) => ({
257
- filePath: file,
258
- filename: path.basename(file),
259
- mimeType: mime.getType(file) || 'application/octet-stream',
260
- }))
294
+ const allFiles: Array<{ data: Uint8Array; filename: string; mimeType: string }> =
295
+ files.map((file) => ({
296
+ data: new Uint8Array(fs.readFileSync(file)),
297
+ filename: path.basename(file),
298
+ mimeType: mime.getType(file) || 'application/octet-stream',
299
+ }))
261
300
 
262
301
  if (isLongPrompt) {
263
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-prompt-'))
264
- const tmpFile = path.join(tmpDir, 'prompt.md')
265
- fs.writeFileSync(tmpFile, prompt)
266
- allFiles.push({ filePath: tmpFile, filename: 'prompt.md', mimeType: 'text/markdown' })
302
+ allFiles.push({
303
+ data: new Uint8Array(Buffer.from(prompt, 'utf8')),
304
+ filename: 'prompt.md',
305
+ mimeType: 'text/markdown',
306
+ })
267
307
  }
268
308
 
269
309
  const attachments = allFiles.map((f, index) => ({
@@ -283,10 +323,9 @@ export async function sendDiscordMessageWithOptionalAttachment({
283
323
  )
284
324
 
285
325
  for (const [index, f] of allFiles.entries()) {
286
- const buffer = fs.readFileSync(f.filePath)
287
326
  formData.append(
288
327
  `files[${index}]`,
289
- new Blob([buffer], { type: f.mimeType }),
328
+ new Blob([f.data], { type: f.mimeType }),
290
329
  f.filename,
291
330
  )
292
331
  }
@@ -344,82 +383,42 @@ export async function sendDiscordMessageWithOptionalAttachment({
344
383
 
345
384
  const preview = prompt.slice(0, 100).replace(/\n/g, ' ')
346
385
  const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`
386
+ // In-memory Blob only — no temp file. Parallel send must never share a path.
387
+ const formData = new FormData()
388
+ formData.append(
389
+ 'payload_json',
390
+ JSON.stringify({
391
+ content: summaryContent,
392
+ attachments: [{ id: 0, filename: 'prompt.md' }],
393
+ embeds,
394
+ allowed_mentions: { parse: store.getState().allowedMentions },
395
+ }),
396
+ )
397
+ formData.append(
398
+ 'files[0]',
399
+ promptAttachmentBlob(wrapPromptAttachmentText(prompt)),
400
+ 'prompt.md',
401
+ )
347
402
 
348
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-prompt-'))
349
- const tmpFile = path.join(tmpDir, 'prompt.md')
350
- // Wrap long lines so the file is readable in Discord's preview
351
- // (Discord doesn't wrap text in file attachments)
352
- const wrappedPrompt = prompt
353
- .split('\n')
354
- .flatMap((line) => {
355
- if (line.length <= 120) {
356
- return [line]
357
- }
358
- const wrapped: string[] = []
359
- let remaining = line
360
- const maxCol = 120
361
- // Only soft-break at a space if it's reasonably close to maxCol,
362
- // otherwise hard-break to avoid tiny fragments from early spaces
363
- const minSoftBreak = 90
364
- while (remaining.length > maxCol) {
365
- const lastSpace = remaining.lastIndexOf(' ', maxCol)
366
- const useSoftBreak = lastSpace >= minSoftBreak
367
- const breakAt = useSoftBreak ? lastSpace : maxCol
368
- wrapped.push(remaining.slice(0, breakAt))
369
- // Only consume the separator space on soft breaks
370
- remaining = useSoftBreak
371
- ? remaining.slice(breakAt + 1)
372
- : remaining.slice(breakAt)
373
- }
374
- if (remaining.length > 0) {
375
- wrapped.push(remaining)
376
- }
377
- return wrapped
378
- })
379
- .join('\n')
380
- fs.writeFileSync(tmpFile, wrappedPrompt)
381
-
382
- try {
383
- const formData = new FormData()
384
- formData.append(
385
- 'payload_json',
386
- JSON.stringify({
387
- content: summaryContent,
388
- attachments: [{ id: 0, filename: 'prompt.md' }],
389
- embeds,
390
- allowed_mentions: { parse: store.getState().allowedMentions },
391
- }),
392
- )
393
- const buffer = fs.readFileSync(tmpFile)
394
- formData.append(
395
- 'files[0]',
396
- new Blob([buffer], { type: 'text/markdown' }),
397
- 'prompt.md',
398
- )
399
-
400
- const starterMessageResponse = await fetch(
401
- discordApiUrl(`/channels/${channelId}/messages`),
402
- {
403
- method: 'POST',
404
- headers: {
405
- Authorization: `Bot ${botToken}`,
406
- },
407
- body: formData,
403
+ const starterMessageResponse = await fetch(
404
+ discordApiUrl(`/channels/${channelId}/messages`),
405
+ {
406
+ method: 'POST',
407
+ headers: {
408
+ Authorization: `Bot ${botToken}`,
408
409
  },
409
- )
410
-
411
- if (!starterMessageResponse.ok) {
412
- const error = await starterMessageResponse.text()
413
- throw new Error(
414
- `Discord API error: ${starterMessageResponse.status} - ${error}`,
415
- )
416
- }
410
+ body: formData,
411
+ },
412
+ )
417
413
 
418
- return (await starterMessageResponse.json()) as { id: string }
419
- } finally {
420
- fs.unlinkSync(tmpFile)
421
- fs.rmdirSync(tmpDir)
414
+ if (!starterMessageResponse.ok) {
415
+ const error = await starterMessageResponse.text()
416
+ throw new Error(
417
+ `Discord API error: ${starterMessageResponse.status} - ${error}`,
418
+ )
422
419
  }
420
+
421
+ return (await starterMessageResponse.json()) as { id: string }
423
422
  }
424
423
 
425
424
  export function formatRelativeTime(target: Date): string {
@@ -465,7 +464,7 @@ export type GuildMemberSearchResult = {
465
464
 
466
465
  export type DiscordUserTarget = {
467
466
  id: string
468
- username: string
467
+ username?: string
469
468
  }
470
469
 
471
470
  export function isGuildMemberSearchResult(value: object | null): value is GuildMemberSearchResult {
@@ -615,7 +614,7 @@ export async function resolveDiscordUserOption({
615
614
  const directUserId = getDiscordUserIdFromUserOption(user)
616
615
  if (directUserId) {
617
616
  cliLogger.log(`Using Discord user ID: ${directUserId}`)
618
- return { id: directUserId, username: directUserId }
617
+ return { id: directUserId }
619
618
  }
620
619
 
621
620
  cliLogger.log(`Searching for user "${user}" in guild...`)
@@ -11,6 +11,7 @@ import {
11
11
  ChannelType,
12
12
  ThreadAutoArchiveDuration,
13
13
  type ThreadChannel,
14
+ type TextChannel,
14
15
  MessageFlags,
15
16
  } from 'discord.js'
16
17
  import crypto from 'node:crypto'
@@ -32,7 +33,11 @@ import {
32
33
  } from '../discord-utils.js'
33
34
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
34
35
  import { createLogger, LogPrefix } from '../logger.js'
35
- import { getCurrentModelInfo } from './model.js'
36
+ import {
37
+ formatModelSource,
38
+ getCurrentModelInfo,
39
+ type CurrentModelInfo,
40
+ } from './model.js'
36
41
  import { isGitRepositoryRoot } from '../worktrees.js'
37
42
  import {
38
43
  formatAutoWorktreeName,
@@ -45,24 +50,18 @@ import { store } from '../store.js'
45
50
  const agentLogger = createLogger(LogPrefix.AGENT)
46
51
 
47
52
  const AGENT_CONTEXT_TTL_MS = 10 * 60 * 1000
48
- const pendingAgentContexts = new Map<
49
- string,
50
- {
51
- dir: string
52
- channelId: string
53
- sessionId?: string
54
- isThread: boolean
55
- }
56
- >()
53
+ const pendingAgentContexts = new Map<string, AgentCommandContext>()
57
54
 
58
55
  /**
59
56
  * Context for agent commands, containing channel/session info.
60
57
  */
61
58
  export type AgentCommandContext = {
62
59
  dir: string
60
+ workingDirectory: string
63
61
  channelId: string
64
62
  sessionId?: string
65
63
  isThread: boolean
64
+ appId: string
66
65
  }
67
66
 
68
67
  export type CurrentAgentInfo =
@@ -188,6 +187,7 @@ async function resolveQuickAgentNameFromInteraction({
188
187
  */
189
188
  export async function resolveAgentCommandContext({
190
189
  interaction,
190
+ appId,
191
191
  }: {
192
192
  interaction: ChatInputCommandInteraction
193
193
  appId: string
@@ -207,41 +207,40 @@ export async function resolveAgentCommandContext({
207
207
  ChannelType.AnnouncementThread,
208
208
  ].includes(channel.type)
209
209
 
210
- let projectDirectory: string | undefined
211
- let targetChannelId: string
212
- let sessionId: string | undefined
213
-
214
- if (isThread) {
215
- const thread = channel as ThreadChannel
216
- const textChannel = await resolveTextChannel(thread)
217
- const metadata = await getKimakiMetadata(textChannel)
218
- projectDirectory = metadata.projectDirectory
219
- targetChannelId = textChannel?.id || channel.id
220
-
221
- sessionId = await getThreadSession(thread.id)
222
- } else if (channel.type === ChannelType.GuildText) {
223
- const metadata = await getKimakiMetadata(channel)
224
- projectDirectory = metadata.projectDirectory
225
- targetChannelId = channel.id
226
- } else {
210
+ if (
211
+ !isThread &&
212
+ channel.type !== ChannelType.GuildText
213
+ ) {
227
214
  await interaction.editReply({
228
215
  content: 'This command can only be used in text channels or threads',
229
216
  })
230
217
  return null
231
218
  }
232
219
 
233
- if (!projectDirectory) {
220
+ const resolved = await resolveWorkingDirectory({
221
+ channel: channel as TextChannel | ThreadChannel,
222
+ })
223
+ if (!resolved) {
234
224
  await interaction.editReply({
235
225
  content: 'This channel is not configured with a project directory',
236
226
  })
237
227
  return null
238
228
  }
239
229
 
230
+ const textChannel = isThread
231
+ ? await resolveTextChannel(channel as ThreadChannel)
232
+ : undefined
233
+ const sessionId = isThread
234
+ ? await getThreadSession(channel.id)
235
+ : undefined
236
+
240
237
  return {
241
- dir: projectDirectory,
242
- channelId: targetChannelId,
238
+ dir: resolved.projectDirectory,
239
+ workingDirectory: resolved.workingDirectory,
240
+ channelId: textChannel?.id || channel.id,
243
241
  sessionId,
244
242
  isThread,
243
+ appId,
245
244
  }
246
245
  }
247
246
 
@@ -270,6 +269,60 @@ export async function setAgentForContext({
270
269
  }
271
270
  }
272
271
 
272
+ function formatAgentModelLine(modelInfo: CurrentModelInfo): string {
273
+ if (modelInfo.type === 'none') return ''
274
+ const source = formatModelSource({
275
+ type: modelInfo.type,
276
+ agentName: modelInfo.type === 'agent' ? modelInfo.agentName : undefined,
277
+ })
278
+ const line = `\nModel: *${modelInfo.model}* (${source})`
279
+ if (modelInfo.type !== 'session' && modelInfo.type !== 'channel') {
280
+ return line
281
+ }
282
+ return `${line}\nThis model comes from a ${modelInfo.type} override. Use /model and press Clear override if you want this agent's model.`
283
+ }
284
+
285
+ function formatAgentPreferenceReply({
286
+ agentName,
287
+ previousAgentName,
288
+ modelInfo,
289
+ scope,
290
+ }: {
291
+ agentName: string
292
+ previousAgentName?: string
293
+ modelInfo: CurrentModelInfo
294
+ scope: 'session' | 'channel'
295
+ }): string {
296
+ const sameAgent = previousAgentName === agentName
297
+ const verb = sameAgent ? 'Using' : 'Switched to'
298
+ const previousText =
299
+ !sameAgent && previousAgentName ? ` (was **${previousAgentName}**)` : ''
300
+ const modelText = formatAgentModelLine(modelInfo)
301
+ if (scope === 'session') {
302
+ return `${verb} **${agentName}** agent for this session${previousText}${modelText}\nThe agent will change on the next message.`
303
+ }
304
+ return `${verb} **${agentName}** agent for this channel${previousText}${modelText}\nAll new sessions will use this agent.`
305
+ }
306
+
307
+ async function resolveAgentModelInfo({
308
+ context,
309
+ agentName,
310
+ }: {
311
+ context: AgentCommandContext
312
+ agentName: string
313
+ }): Promise<CurrentModelInfo> {
314
+ const getClient = await initializeOpencodeForDirectory(context.dir)
315
+ if (getClient instanceof Error) return { type: 'none' }
316
+ return getCurrentModelInfo({
317
+ sessionId: context.sessionId,
318
+ channelId: context.channelId,
319
+ appId: context.appId,
320
+ agentPreference: agentName,
321
+ getClient,
322
+ directory: context.workingDirectory,
323
+ })
324
+ }
325
+
273
326
  export async function handleAgentCommand({
274
327
  interaction,
275
328
  appId,
@@ -292,7 +345,7 @@ export async function handleAgentCommand({
292
345
  }
293
346
 
294
347
  const agentsResponse = await getClient().app.agents({
295
- directory: context.dir,
348
+ directory: context.workingDirectory,
296
349
  })
297
350
 
298
351
  if (!agentsResponse.data || agentsResponse.data.length === 0) {
@@ -392,19 +445,30 @@ export async function handleAgentSelectMenu(
392
445
  }
393
446
 
394
447
  try {
448
+ const previousAgent = await getCurrentAgentInfo({
449
+ sessionId: context.sessionId,
450
+ channelId: context.channelId,
451
+ })
452
+ const previousAgentName =
453
+ previousAgent.type !== 'none' ? previousAgent.agent : undefined
454
+
395
455
  await setAgentForContext({ context, agentName: selectedAgent })
456
+ const modelInfo = await resolveAgentModelInfo({
457
+ context,
458
+ agentName: selectedAgent,
459
+ })
460
+ const scope =
461
+ context.isThread && context.sessionId ? 'session' : 'channel'
396
462
 
397
- if (context.isThread && context.sessionId) {
398
- await interaction.editReply({
399
- content: `Agent preference set for this session: **${selectedAgent}**\nThe agent will change on the next message.`,
400
- components: [],
401
- })
402
- } else {
403
- await interaction.editReply({
404
- content: `Agent preference set for this channel: **${selectedAgent}**\nAll new sessions in this channel will use this agent.`,
405
- components: [],
406
- })
407
- }
463
+ await interaction.editReply({
464
+ content: formatAgentPreferenceReply({
465
+ agentName: selectedAgent,
466
+ previousAgentName,
467
+ modelInfo,
468
+ scope,
469
+ }),
470
+ components: [],
471
+ })
408
472
 
409
473
  pendingAgentContexts.delete(contextHash)
410
474
  } catch (error) {
@@ -465,51 +529,23 @@ export async function handleQuickAgentCommand({
465
529
  const previousAgentName =
466
530
  previousAgent.type !== 'none' ? previousAgent.agent : undefined
467
531
 
468
- if (previousAgentName === resolvedAgentName) {
469
- await command.editReply({
470
- content: `Already using **${resolvedAgentName}** agent`,
471
- })
472
- return
473
- }
474
-
475
- // Set the agent preference in DB for this context.
476
532
  await setAgentForContext({ context, agentName: resolvedAgentName })
477
533
 
478
- const previousText = previousAgentName
479
- ? ` (was **${previousAgentName}**)`
480
- : ''
481
-
482
- // Resolve the model that will now be used for the new agent so we can
483
- // show it in the reply. setAgentForContext already cleared any session
484
- // model preference, so getCurrentModelInfo falls through to the agent's
485
- // configured model (or channel/global/default).
486
- const modelInfo = await (async () => {
487
- const getClient = await initializeOpencodeForDirectory(context.dir)
488
- if (getClient instanceof Error) {
489
- return { type: 'none' as const }
490
- }
491
- return getCurrentModelInfo({
492
- sessionId: context.sessionId,
493
- channelId: context.channelId,
494
- appId,
495
- agentPreference: resolvedAgentName,
496
- getClient,
497
- directory: context.dir,
498
- })
499
- })()
500
-
501
- const modelText =
502
- modelInfo.type === 'none' ? '' : `\nModel: *${modelInfo.model}*`
534
+ const modelInfo = await resolveAgentModelInfo({
535
+ context,
536
+ agentName: resolvedAgentName,
537
+ })
538
+ const scope =
539
+ context.isThread && context.sessionId ? 'session' : 'channel'
503
540
 
504
- if (context.isThread && context.sessionId) {
505
- await command.editReply({
506
- content: `Switched to **${resolvedAgentName}** agent for this session${previousText}${modelText}\nThe agent will change on the next message.`,
507
- })
508
- } else {
509
- await command.editReply({
510
- content: `Switched to **${resolvedAgentName}** agent for this channel${previousText}${modelText}\nAll new sessions will use this agent.`,
511
- })
512
- }
541
+ await command.editReply({
542
+ content: formatAgentPreferenceReply({
543
+ agentName: resolvedAgentName,
544
+ previousAgentName,
545
+ modelInfo,
546
+ scope,
547
+ }),
548
+ })
513
549
  } catch (error) {
514
550
  agentLogger.error('Error in quick agent command:', error)
515
551
  await command.editReply({
@@ -150,6 +150,31 @@ export type CurrentModelInfo =
150
150
  }
151
151
  | { type: 'none' }
152
152
 
153
+ export function formatModelSource({
154
+ type,
155
+ agentName,
156
+ }: {
157
+ type: CurrentModelInfo['type'] | string
158
+ agentName?: string
159
+ }): string {
160
+ switch (type) {
161
+ case 'session':
162
+ return 'session override'
163
+ case 'agent':
164
+ return `agent "${agentName}"`
165
+ case 'channel':
166
+ return 'channel override'
167
+ case 'global':
168
+ return 'global default'
169
+ case 'opencode-config':
170
+ case 'opencode-recent':
171
+ case 'opencode-provider-default':
172
+ return 'opencode default'
173
+ default:
174
+ return 'none'
175
+ }
176
+ }
177
+
153
178
  function parseModelId(
154
179
  modelString: string,
155
180
  ): { providerID: string; modelID: string } | undefined {