kimaki 0.4.44 → 0.4.46

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 (45) hide show
  1. package/dist/channel-management.js +6 -15
  2. package/dist/cli.js +54 -37
  3. package/dist/commands/create-new-project.js +2 -0
  4. package/dist/commands/fork.js +2 -0
  5. package/dist/commands/permissions.js +21 -5
  6. package/dist/commands/queue.js +5 -1
  7. package/dist/commands/resume.js +10 -16
  8. package/dist/commands/session.js +20 -42
  9. package/dist/commands/user-command.js +10 -17
  10. package/dist/commands/verbosity.js +53 -0
  11. package/dist/commands/worktree-settings.js +2 -2
  12. package/dist/commands/worktree.js +134 -25
  13. package/dist/database.js +49 -0
  14. package/dist/discord-bot.js +26 -38
  15. package/dist/discord-utils.js +51 -13
  16. package/dist/discord-utils.test.js +20 -0
  17. package/dist/escape-backticks.test.js +14 -3
  18. package/dist/interaction-handler.js +4 -0
  19. package/dist/session-handler.js +581 -414
  20. package/package.json +1 -1
  21. package/src/__snapshots__/first-session-no-info.md +1344 -0
  22. package/src/__snapshots__/first-session-with-info.md +1350 -0
  23. package/src/__snapshots__/session-1.md +1344 -0
  24. package/src/__snapshots__/session-2.md +291 -0
  25. package/src/__snapshots__/session-3.md +20324 -0
  26. package/src/__snapshots__/session-with-tools.md +1344 -0
  27. package/src/channel-management.ts +6 -17
  28. package/src/cli.ts +63 -45
  29. package/src/commands/create-new-project.ts +3 -0
  30. package/src/commands/fork.ts +3 -0
  31. package/src/commands/permissions.ts +31 -5
  32. package/src/commands/queue.ts +5 -1
  33. package/src/commands/resume.ts +11 -18
  34. package/src/commands/session.ts +21 -44
  35. package/src/commands/user-command.ts +11 -19
  36. package/src/commands/verbosity.ts +71 -0
  37. package/src/commands/worktree-settings.ts +2 -2
  38. package/src/commands/worktree.ts +163 -27
  39. package/src/database.ts +65 -0
  40. package/src/discord-bot.ts +29 -42
  41. package/src/discord-utils.test.ts +23 -0
  42. package/src/discord-utils.ts +52 -13
  43. package/src/escape-backticks.test.ts +14 -3
  44. package/src/interaction-handler.ts +5 -0
  45. package/src/session-handler.ts +711 -436
@@ -4,8 +4,7 @@
4
4
 
5
5
  import { ChannelType, type CategoryChannel, type Guild, type TextChannel } from 'discord.js'
6
6
  import path from 'node:path'
7
- import { getDatabase } from './database.js'
8
- import { extractTagsArrays } from './xml.js'
7
+ import { getDatabase, getChannelDirectory } from './database.js'
9
8
 
10
9
  export async function ensureKimakiCategory(
11
10
  guild: Guild,
@@ -83,7 +82,7 @@ export async function createProjectChannels({
83
82
  name: channelName,
84
83
  type: ChannelType.GuildText,
85
84
  parent: kimakiCategory,
86
- topic: `<kimaki><directory>${projectDirectory}</directory><app>${appId}</app></kimaki>`,
85
+ // Channel configuration is stored in SQLite, not in the topic
87
86
  })
88
87
 
89
88
  const voiceChannel = await guild.channels.create({
@@ -128,25 +127,15 @@ export async function getChannelsWithDescriptions(guild: Guild): Promise<Channel
128
127
  const textChannel = channel as TextChannel
129
128
  const description = textChannel.topic || null
130
129
 
131
- let kimakiDirectory: string | undefined
132
- let kimakiApp: string | undefined
133
-
134
- if (description) {
135
- const extracted = extractTagsArrays({
136
- xml: description,
137
- tags: ['kimaki.directory', 'kimaki.app'],
138
- })
139
-
140
- kimakiDirectory = extracted['kimaki.directory']?.[0]?.trim()
141
- kimakiApp = extracted['kimaki.app']?.[0]?.trim()
142
- }
130
+ // Get channel config from database instead of parsing XML from topic
131
+ const channelConfig = getChannelDirectory(textChannel.id)
143
132
 
144
133
  channels.push({
145
134
  id: textChannel.id,
146
135
  name: textChannel.name,
147
136
  description,
148
- kimakiDirectory,
149
- kimakiApp,
137
+ kimakiDirectory: channelConfig?.directory,
138
+ kimakiApp: channelConfig?.appId || undefined,
150
139
  })
151
140
  })
152
141
 
package/src/cli.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  getChannelsWithDescriptions,
22
22
  createDiscordClient,
23
23
  getDatabase,
24
+ getChannelDirectory,
24
25
  startDiscordBot,
25
26
  initializeOpencodeForDirectory,
26
27
  ensureKimakiCategory,
@@ -47,7 +48,6 @@ import { uploadFilesToDiscord } from './discord-utils.js'
47
48
  import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
48
49
  import http from 'node:http'
49
50
  import { setDataDir, getDataDir, getLockPort } from './config.js'
50
- import { extractTagsArrays } from './xml.js'
51
51
  import { sanitizeAgentName } from './commands/agent.js'
52
52
 
53
53
  const cliLogger = createLogger('CLI')
@@ -240,12 +240,12 @@ async function registerCommands({
240
240
  .toJSON(),
241
241
  new SlashCommandBuilder()
242
242
  .setName('new-worktree')
243
- .setDescription('Create a new git worktree and start a session thread')
243
+ .setDescription('Create a new git worktree (in thread: uses thread name if no name given)')
244
244
  .addStringOption((option) => {
245
245
  option
246
246
  .setName('name')
247
- .setDescription('Name for the worktree (will be formatted: lowercase, spaces to dashes)')
248
- .setRequired(true)
247
+ .setDescription('Name for worktree (optional in threads - uses thread name)')
248
+ .setRequired(false)
249
249
 
250
250
  return option
251
251
  })
@@ -342,6 +342,21 @@ async function registerCommands({
342
342
  .setName('redo')
343
343
  .setDescription('Redo previously undone changes')
344
344
  .toJSON(),
345
+ new SlashCommandBuilder()
346
+ .setName('verbosity')
347
+ .setDescription('Set output verbosity for new sessions in this channel')
348
+ .addStringOption((option) => {
349
+ option
350
+ .setName('level')
351
+ .setDescription('Verbosity level')
352
+ .setRequired(true)
353
+ .addChoices(
354
+ { name: 'tools-and-text (default)', value: 'tools-and-text' },
355
+ { name: 'text-only', value: 'text-only' },
356
+ )
357
+ return option
358
+ })
359
+ .toJSON(),
345
360
  ]
346
361
 
347
362
  // Add user-defined commands with -cmd suffix
@@ -1367,25 +1382,17 @@ cli
1367
1382
  guild_id: string
1368
1383
  }
1369
1384
 
1370
- if (!channelData.topic) {
1371
- s.stop('Channel has no topic')
1385
+ const channelConfig = getChannelDirectory(channelData.id)
1386
+
1387
+ if (!channelConfig) {
1388
+ s.stop('Channel not configured')
1372
1389
  throw new Error(
1373
- `Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`,
1390
+ `Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`,
1374
1391
  )
1375
1392
  }
1376
1393
 
1377
- const extracted = extractTagsArrays({
1378
- xml: channelData.topic,
1379
- tags: ['kimaki.directory', 'kimaki.app'],
1380
- })
1381
-
1382
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
1383
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
1384
-
1385
- if (!projectDirectory) {
1386
- s.stop('No kimaki.directory tag found')
1387
- throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`)
1388
- }
1394
+ const projectDirectory = channelConfig.directory
1395
+ const channelAppId = channelConfig.appId || undefined
1389
1396
 
1390
1397
  // Verify app ID matches if both are present
1391
1398
  if (channelAppId && appId && channelAppId !== appId) {
@@ -1599,30 +1606,7 @@ cli
1599
1606
  }
1600
1607
 
1601
1608
  const s = spinner()
1602
- s.start('Checking for existing channel...')
1603
-
1604
- // Check if channel already exists
1605
- try {
1606
- const db = getDatabase()
1607
- const existingChannel = db
1608
- .prepare(
1609
- 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?',
1610
- )
1611
- .get(absolutePath, 'text', appId) as { channel_id: string } | undefined
1612
-
1613
- if (existingChannel) {
1614
- s.stop('Channel already exists')
1615
- note(
1616
- `Channel already exists for this directory.\n\nChannel: <#${existingChannel.channel_id}>\nDirectory: ${absolutePath}`,
1617
- '⚠️ Already Exists',
1618
- )
1619
- process.exit(0)
1620
- }
1621
- } catch {
1622
- // Database might not exist, continue to create
1623
- }
1624
-
1625
- s.message('Connecting to Discord...')
1609
+ s.start('Connecting to Discord...')
1626
1610
  const client = await createDiscordClient()
1627
1611
 
1628
1612
  await new Promise<void>((resolve, reject) => {
@@ -1638,10 +1622,14 @@ cli
1638
1622
  // Find guild
1639
1623
  let guild: Guild
1640
1624
  if (options.guild) {
1641
- const foundGuild = client.guilds.cache.get(options.guild)
1625
+ // Get raw guild ID from argv to avoid cac's number coercion losing precision on large IDs
1626
+ const guildArgIndex = process.argv.findIndex((arg) => arg === '-g' || arg === '--guild')
1627
+ const rawGuildArg = guildArgIndex >= 0 ? process.argv[guildArgIndex + 1] : undefined
1628
+ const guildId = rawGuildArg || String(options.guild)
1629
+ const foundGuild = client.guilds.cache.get(guildId)
1642
1630
  if (!foundGuild) {
1643
1631
  s.stop('Guild not found')
1644
- cliLogger.error(`Guild not found: ${options.guild}`)
1632
+ cliLogger.error(`Guild not found: ${guildId}`)
1645
1633
  client.destroy()
1646
1634
  process.exit(EXIT_NO_RESTART)
1647
1635
  }
@@ -1686,6 +1674,36 @@ cli
1686
1674
  }
1687
1675
  }
1688
1676
 
1677
+ // Check if channel already exists in this guild
1678
+ s.message('Checking for existing channel...')
1679
+ try {
1680
+ const db = getDatabase()
1681
+ const existingChannels = db
1682
+ .prepare(
1683
+ 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?',
1684
+ )
1685
+ .all(absolutePath, 'text', appId) as { channel_id: string }[]
1686
+
1687
+ for (const existingChannel of existingChannels) {
1688
+ try {
1689
+ const ch = await client.channels.fetch(existingChannel.channel_id)
1690
+ if (ch && 'guild' in ch && ch.guild?.id === guild.id) {
1691
+ s.stop('Channel already exists')
1692
+ note(
1693
+ `Channel already exists for this directory in ${guild.name}.\n\nChannel: <#${existingChannel.channel_id}>\nDirectory: ${absolutePath}`,
1694
+ '⚠️ Already Exists',
1695
+ )
1696
+ client.destroy()
1697
+ process.exit(0)
1698
+ }
1699
+ } catch {
1700
+ // Channel might be deleted, continue checking
1701
+ }
1702
+ }
1703
+ } catch {
1704
+ // Database might not exist, continue to create
1705
+ }
1706
+
1689
1707
  s.message(`Creating channels in ${guild.name}...`)
1690
1708
 
1691
1709
  const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
@@ -88,6 +88,9 @@ export async function handleCreateNewProjectCommand({
88
88
  reason: 'New project session',
89
89
  })
90
90
 
91
+ // Add user to thread so it appears in their sidebar
92
+ await thread.members.add(command.user.id)
93
+
91
94
  await handleOpencodeSession({
92
95
  prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
93
96
  thread,
@@ -215,6 +215,9 @@ export async function handleForkSelectMenu(
215
215
  reason: `Forked from session ${sessionId}`,
216
216
  })
217
217
 
218
+ // Add user to thread so it appears in their sidebar
219
+ await thread.members.add(interaction.user.id)
220
+
218
221
  getDatabase()
219
222
  .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
220
223
  .run(thread.id, forkedSession.id)
@@ -18,6 +18,7 @@ const logger = createLogger('PERMISSIONS')
18
18
 
19
19
  type PendingPermissionContext = {
20
20
  permission: PermissionRequest
21
+ requestIds: string[]
21
22
  directory: string
22
23
  thread: ThreadChannel
23
24
  contextHash: string
@@ -43,6 +44,7 @@ export async function showPermissionDropdown({
43
44
 
44
45
  const context: PendingPermissionContext = {
45
46
  permission,
47
+ requestIds: [permission.id],
46
48
  directory,
47
49
  thread,
48
50
  contextHash,
@@ -124,10 +126,15 @@ export async function handlePermissionSelectMenu(
124
126
  if (!clientV2) {
125
127
  throw new Error('OpenCode server not found for directory')
126
128
  }
127
- await clientV2.permission.reply({
128
- requestID: context.permission.id,
129
- reply: response,
130
- })
129
+ const requestIds = context.requestIds.length > 0 ? context.requestIds : [context.permission.id]
130
+ await Promise.all(
131
+ requestIds.map((requestId) => {
132
+ return clientV2.permission.reply({
133
+ requestID: requestId,
134
+ reply: response,
135
+ })
136
+ }),
137
+ )
131
138
 
132
139
  pendingPermissionContexts.delete(contextHash)
133
140
 
@@ -153,7 +160,7 @@ export async function handlePermissionSelectMenu(
153
160
  components: [], // Remove the dropdown
154
161
  })
155
162
 
156
- logger.log(`Permission ${context.permission.id} ${response}`)
163
+ logger.log(`Permission ${context.permission.id} ${response} (${requestIds.length} request(s))`)
157
164
  } catch (error) {
158
165
  logger.error('Error handling permission:', error)
159
166
  await interaction.editReply({
@@ -163,6 +170,25 @@ export async function handlePermissionSelectMenu(
163
170
  }
164
171
  }
165
172
 
173
+ export function addPermissionRequestToContext({
174
+ contextHash,
175
+ requestId,
176
+ }: {
177
+ contextHash: string
178
+ requestId: string
179
+ }): boolean {
180
+ const context = pendingPermissionContexts.get(contextHash)
181
+ if (!context) {
182
+ return false
183
+ }
184
+ if (context.requestIds.includes(requestId)) {
185
+ return false
186
+ }
187
+ context.requestIds = [...context.requestIds, requestId]
188
+ pendingPermissionContexts.set(contextHash, context)
189
+ return true
190
+ }
191
+
166
192
  /**
167
193
  * Clean up a pending permission context (e.g., on auto-reject).
168
194
  */
@@ -62,7 +62,11 @@ export async function handleQueueCommand({ command }: CommandContext): Promise<v
62
62
  }
63
63
 
64
64
  // Check if there's an active request running
65
- const hasActiveRequest = abortControllers.has(row.session_id)
65
+ const existingController = abortControllers.get(row.session_id)
66
+ const hasActiveRequest = Boolean(existingController && !existingController.signal.aborted)
67
+ if (existingController && existingController.signal.aborted) {
68
+ abortControllers.delete(row.session_id)
69
+ }
66
70
 
67
71
  if (!hasActiveRequest) {
68
72
  // No active request, send immediately
@@ -8,10 +8,9 @@ import {
8
8
  } from 'discord.js'
9
9
  import fs from 'node:fs'
10
10
  import type { CommandContext, AutocompleteContext } from './types.js'
11
- import { getDatabase } from '../database.js'
11
+ import { getDatabase, getChannelDirectory } from '../database.js'
12
12
  import { initializeOpencodeForDirectory } from '../opencode.js'
13
- import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
14
- import { extractTagsArrays } from '../xml.js'
13
+ import { sendThreadMessage, resolveTextChannel } from '../discord-utils.js'
15
14
  import { collectLastAssistantParts } from '../message-formatting.js'
16
15
  import { createLogger } from '../logger.js'
17
16
  import * as errore from 'errore'
@@ -31,18 +30,9 @@ export async function handleResumeCommand({ command, appId }: CommandContext): P
31
30
 
32
31
  const textChannel = channel as TextChannel
33
32
 
34
- let projectDirectory: string | undefined
35
- let channelAppId: string | undefined
36
-
37
- if (textChannel.topic) {
38
- const extracted = extractTagsArrays({
39
- xml: textChannel.topic,
40
- tags: ['kimaki.directory', 'kimaki.app'],
41
- })
42
-
43
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
44
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
45
- }
33
+ const channelConfig = getChannelDirectory(textChannel.id)
34
+ const projectDirectory = channelConfig?.directory
35
+ const channelAppId = channelConfig?.appId || undefined
46
36
 
47
37
  if (channelAppId && channelAppId !== appId) {
48
38
  await command.editReply('This channel is not configured for this bot')
@@ -83,6 +73,9 @@ export async function handleResumeCommand({ command, appId }: CommandContext): P
83
73
  reason: `Resuming session ${sessionId}`,
84
74
  })
85
75
 
76
+ // Add user to thread so it appears in their sidebar
77
+ await thread.members.add(command.user.id)
78
+
86
79
  getDatabase()
87
80
  .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
88
81
  .run(thread.id, sessionId)
@@ -157,12 +150,12 @@ export async function handleResumeAutocomplete({
157
150
  interaction.channel as TextChannel | ThreadChannel | null,
158
151
  )
159
152
  if (textChannel) {
160
- const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel)
161
- if (channelAppId && channelAppId !== appId) {
153
+ const channelConfig = getChannelDirectory(textChannel.id)
154
+ if (channelConfig?.appId && channelConfig.appId !== appId) {
162
155
  await interaction.respond([])
163
156
  return
164
157
  }
165
- projectDirectory = directory
158
+ projectDirectory = channelConfig?.directory
166
159
  }
167
160
  }
168
161
 
@@ -4,10 +4,9 @@ import { ChannelType, type TextChannel } from 'discord.js'
4
4
  import fs from 'node:fs'
5
5
  import path from 'node:path'
6
6
  import type { CommandContext, AutocompleteContext } from './types.js'
7
- import { getDatabase } from '../database.js'
7
+ import { getDatabase, getChannelDirectory } from '../database.js'
8
8
  import { initializeOpencodeForDirectory } from '../opencode.js'
9
9
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
10
- import { extractTagsArrays } from '../xml.js'
11
10
  import { handleOpencodeSession } from '../session-handler.js'
12
11
  import { createLogger } from '../logger.js'
13
12
  import * as errore from 'errore'
@@ -29,18 +28,9 @@ export async function handleSessionCommand({ command, appId }: CommandContext):
29
28
 
30
29
  const textChannel = channel as TextChannel
31
30
 
32
- let projectDirectory: string | undefined
33
- let channelAppId: string | undefined
34
-
35
- if (textChannel.topic) {
36
- const extracted = extractTagsArrays({
37
- xml: textChannel.topic,
38
- tags: ['kimaki.directory', 'kimaki.app'],
39
- })
40
-
41
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
42
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
43
- }
31
+ const channelConfig = getChannelDirectory(textChannel.id)
32
+ const projectDirectory = channelConfig?.directory
33
+ const channelAppId = channelConfig?.appId || undefined
44
34
 
45
35
  if (channelAppId && channelAppId !== appId) {
46
36
  await command.editReply('This channel is not configured for this bot')
@@ -85,6 +75,9 @@ export async function handleSessionCommand({ command, appId }: CommandContext):
85
75
  reason: 'OpenCode session',
86
76
  })
87
77
 
78
+ // Add user to thread so it appears in their sidebar
79
+ await thread.members.add(command.user.id)
80
+
88
81
  await command.editReply(`Created new session in ${thread.toString()}`)
89
82
 
90
83
  await handleOpencodeSession({
@@ -107,22 +100,14 @@ async function handleAgentAutocomplete({ interaction, appId }: AutocompleteConte
107
100
 
108
101
  let projectDirectory: string | undefined
109
102
 
110
- if (interaction.channel) {
111
- const channel = interaction.channel
112
- if (channel.type === ChannelType.GuildText) {
113
- const textChannel = channel as TextChannel
114
- if (textChannel.topic) {
115
- const extracted = extractTagsArrays({
116
- xml: textChannel.topic,
117
- tags: ['kimaki.directory', 'kimaki.app'],
118
- })
119
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
120
- if (channelAppId && channelAppId !== appId) {
121
- await interaction.respond([])
122
- return
123
- }
124
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
103
+ if (interaction.channel && interaction.channel.type === ChannelType.GuildText) {
104
+ const channelConfig = getChannelDirectory(interaction.channel.id)
105
+ if (channelConfig) {
106
+ if (channelConfig.appId && channelConfig.appId !== appId) {
107
+ await interaction.respond([])
108
+ return
125
109
  }
110
+ projectDirectory = channelConfig.directory
126
111
  }
127
112
  }
128
113
 
@@ -190,22 +175,14 @@ export async function handleSessionAutocomplete({
190
175
 
191
176
  let projectDirectory: string | undefined
192
177
 
193
- if (interaction.channel) {
194
- const channel = interaction.channel
195
- if (channel.type === ChannelType.GuildText) {
196
- const textChannel = channel as TextChannel
197
- if (textChannel.topic) {
198
- const extracted = extractTagsArrays({
199
- xml: textChannel.topic,
200
- tags: ['kimaki.directory', 'kimaki.app'],
201
- })
202
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
203
- if (channelAppId && channelAppId !== appId) {
204
- await interaction.respond([])
205
- return
206
- }
207
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
178
+ if (interaction.channel && interaction.channel.type === ChannelType.GuildText) {
179
+ const channelConfig = getChannelDirectory(interaction.channel.id)
180
+ if (channelConfig) {
181
+ if (channelConfig.appId && channelConfig.appId !== appId) {
182
+ await interaction.respond([])
183
+ return
208
184
  }
185
+ projectDirectory = channelConfig.directory
209
186
  }
210
187
  }
211
188
 
@@ -3,11 +3,10 @@
3
3
 
4
4
  import type { CommandContext, CommandHandler } from './types.js'
5
5
  import { ChannelType, type TextChannel, type ThreadChannel } from 'discord.js'
6
- import { extractTagsArrays } from '../xml.js'
7
6
  import { handleOpencodeSession } from '../session-handler.js'
8
7
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
9
8
  import { createLogger } from '../logger.js'
10
- import { getDatabase } from '../database.js'
9
+ import { getDatabase, getChannelDirectory } from '../database.js'
11
10
  import fs from 'node:fs'
12
11
 
13
12
  const userCommandLogger = createLogger('USER_CMD')
@@ -68,28 +67,18 @@ export const handleUserCommand: CommandHandler = async ({ command, appId }: Comm
68
67
  return
69
68
  }
70
69
 
71
- if (textChannel?.topic) {
72
- const extracted = extractTagsArrays({
73
- xml: textChannel.topic,
74
- tags: ['kimaki.directory', 'kimaki.app'],
75
- })
76
-
77
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
78
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
70
+ if (textChannel) {
71
+ const channelConfig = getChannelDirectory(textChannel.id)
72
+ projectDirectory = channelConfig?.directory
73
+ channelAppId = channelConfig?.appId || undefined
79
74
  }
80
75
  } else {
81
76
  // Running in a text channel - will create a new thread
82
77
  textChannel = channel as TextChannel
83
78
 
84
- if (textChannel.topic) {
85
- const extracted = extractTagsArrays({
86
- xml: textChannel.topic,
87
- tags: ['kimaki.directory', 'kimaki.app'],
88
- })
89
-
90
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
91
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
92
- }
79
+ const channelConfig = getChannelDirectory(textChannel.id)
80
+ projectDirectory = channelConfig?.directory
81
+ channelAppId = channelConfig?.appId || undefined
93
82
  }
94
83
 
95
84
  if (channelAppId && channelAppId !== appId) {
@@ -147,6 +136,9 @@ export const handleUserCommand: CommandHandler = async ({ command, appId }: Comm
147
136
  reason: `OpenCode command: ${commandName}`,
148
137
  })
149
138
 
139
+ // Add user to thread so it appears in their sidebar
140
+ await newThread.members.add(command.user.id)
141
+
150
142
  await command.editReply(`Started /${commandName} in ${newThread.toString()}`)
151
143
 
152
144
  await handleOpencodeSession({
@@ -0,0 +1,71 @@
1
+ // /verbosity command.
2
+ // Sets the output verbosity level for sessions in a channel.
3
+ // 'tools-and-text' (default): shows all output including tool executions
4
+ // 'text-only': only shows text responses (⬥ diamond parts)
5
+
6
+ import { ChatInputCommandInteraction, ChannelType, type TextChannel, type ThreadChannel } from 'discord.js'
7
+ import { getChannelVerbosity, setChannelVerbosity, type VerbosityLevel } from '../database.js'
8
+ import { createLogger } from '../logger.js'
9
+
10
+ const verbosityLogger = createLogger('VERBOSITY')
11
+
12
+ /**
13
+ * Handle the /verbosity slash command.
14
+ * Sets output verbosity for the channel (applies to new sessions).
15
+ */
16
+ export async function handleVerbosityCommand({
17
+ command,
18
+ appId,
19
+ }: {
20
+ command: ChatInputCommandInteraction
21
+ appId: string
22
+ }): Promise<void> {
23
+ verbosityLogger.log('[VERBOSITY] Command called')
24
+
25
+ const channel = command.channel
26
+ if (!channel) {
27
+ await command.reply({
28
+ content: 'Could not determine channel.',
29
+ ephemeral: true,
30
+ })
31
+ return
32
+ }
33
+
34
+ // Get the parent channel ID (for threads, use parent; for text channels, use self)
35
+ const channelId = (() => {
36
+ if (channel.type === ChannelType.GuildText) {
37
+ return channel.id
38
+ }
39
+ if (
40
+ channel.type === ChannelType.PublicThread ||
41
+ channel.type === ChannelType.PrivateThread ||
42
+ channel.type === ChannelType.AnnouncementThread
43
+ ) {
44
+ return (channel as ThreadChannel).parentId || channel.id
45
+ }
46
+ return channel.id
47
+ })()
48
+
49
+ const level = command.options.getString('level', true) as VerbosityLevel
50
+ const currentLevel = getChannelVerbosity(channelId)
51
+
52
+ if (currentLevel === level) {
53
+ await command.reply({
54
+ content: `Verbosity is already set to **${level}**.`,
55
+ ephemeral: true,
56
+ })
57
+ return
58
+ }
59
+
60
+ setChannelVerbosity(channelId, level)
61
+ verbosityLogger.log(`[VERBOSITY] Set channel ${channelId} to ${level}`)
62
+
63
+ const description = level === 'text-only'
64
+ ? 'Only text responses will be shown. Tool executions, status messages, and thinking will be hidden.'
65
+ : 'All output will be shown, including tool executions and status messages.'
66
+
67
+ await command.reply({
68
+ content: `Verbosity set to **${level}**.\n\n${description}\n\nThis applies to all new sessions in this channel.`,
69
+ ephemeral: true,
70
+ })
71
+ }
@@ -46,7 +46,7 @@ export async function handleEnableWorktreesCommand({
46
46
  if (!metadata.projectDirectory) {
47
47
  await command.reply({
48
48
  content:
49
- 'This channel is not configured with a project directory.\nAdd a `<kimaki.directory>/path/to/project</kimaki.directory>` tag to the channel description.',
49
+ 'This channel is not configured with a project directory.\nUse `/add-project` to set up this channel.',
50
50
  ephemeral: true,
51
51
  })
52
52
  return
@@ -102,7 +102,7 @@ export async function handleDisableWorktreesCommand({
102
102
  if (!metadata.projectDirectory) {
103
103
  await command.reply({
104
104
  content:
105
- 'This channel is not configured with a project directory.\nAdd a `<kimaki.directory>/path/to/project</kimaki.directory>` tag to the channel description.',
105
+ 'This channel is not configured with a project directory.\nUse `/add-project` to set up this channel.',
106
106
  ephemeral: true,
107
107
  })
108
108
  return