kimaki 0.4.22 → 0.4.23

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.
@@ -0,0 +1,1000 @@
1
+ import {
2
+ ChannelType,
3
+ Events,
4
+ ThreadAutoArchiveDuration,
5
+ type Client,
6
+ type Interaction,
7
+ type TextChannel,
8
+ type ThreadChannel,
9
+ } from 'discord.js'
10
+ import fs from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { getDatabase } from './database.js'
14
+ import { initializeOpencodeForDirectory } from './opencode.js'
15
+ import {
16
+ sendThreadMessage,
17
+ resolveTextChannel,
18
+ getKimakiMetadata,
19
+ SILENT_MESSAGE_FLAGS,
20
+ } from './discord-utils.js'
21
+ import { handleForkCommand, handleForkSelectMenu } from './fork.js'
22
+ import {
23
+ handleModelCommand,
24
+ handleProviderSelectMenu,
25
+ handleModelSelectMenu,
26
+ } from './model-command.js'
27
+ import { formatPart } from './message-formatting.js'
28
+ import { createProjectChannels } from './channel-management.js'
29
+ import {
30
+ handleOpencodeSession,
31
+ parseSlashCommand,
32
+ abortControllers,
33
+ pendingPermissions,
34
+ } from './session-handler.js'
35
+ import { extractTagsArrays } from './xml.js'
36
+ import { createLogger } from './logger.js'
37
+
38
+ const discordLogger = createLogger('DISCORD')
39
+ const interactionLogger = createLogger('INTERACTION')
40
+
41
+ export function registerInteractionHandler({
42
+ discordClient,
43
+ appId,
44
+ }: {
45
+ discordClient: Client
46
+ appId: string
47
+ }) {
48
+ interactionLogger.log('[REGISTER] Interaction handler registered')
49
+
50
+ discordClient.on(
51
+ Events.InteractionCreate,
52
+ async (interaction: Interaction) => {
53
+ try {
54
+ interactionLogger.log(`[INTERACTION] Received: ${interaction.type} - ${interaction.isChatInputCommand() ? interaction.commandName : interaction.isAutocomplete() ? `autocomplete:${interaction.commandName}` : 'other'}`)
55
+
56
+ if (interaction.isAutocomplete()) {
57
+ if (interaction.commandName === 'resume') {
58
+ const focusedValue = interaction.options.getFocused()
59
+
60
+ let projectDirectory: string | undefined
61
+ if (interaction.channel) {
62
+ const textChannel = await resolveTextChannel(
63
+ interaction.channel as TextChannel | ThreadChannel | null,
64
+ )
65
+ if (textChannel) {
66
+ const { projectDirectory: directory, channelAppId } =
67
+ getKimakiMetadata(textChannel)
68
+ if (channelAppId && channelAppId !== appId) {
69
+ await interaction.respond([])
70
+ return
71
+ }
72
+ projectDirectory = directory
73
+ }
74
+ }
75
+
76
+ if (!projectDirectory) {
77
+ await interaction.respond([])
78
+ return
79
+ }
80
+
81
+ try {
82
+ const getClient =
83
+ await initializeOpencodeForDirectory(projectDirectory)
84
+
85
+ const sessionsResponse = await getClient().session.list()
86
+ if (!sessionsResponse.data) {
87
+ await interaction.respond([])
88
+ return
89
+ }
90
+
91
+ const existingSessionIds = new Set(
92
+ (
93
+ getDatabase()
94
+ .prepare('SELECT session_id FROM thread_sessions')
95
+ .all() as { session_id: string }[]
96
+ ).map((row) => row.session_id),
97
+ )
98
+
99
+ const sessions = sessionsResponse.data
100
+ .filter((session) => !existingSessionIds.has(session.id))
101
+ .filter((session) =>
102
+ session.title
103
+ .toLowerCase()
104
+ .includes(focusedValue.toLowerCase()),
105
+ )
106
+ .slice(0, 25)
107
+ .map((session) => {
108
+ const dateStr = new Date(
109
+ session.time.updated,
110
+ ).toLocaleString()
111
+ const suffix = ` (${dateStr})`
112
+ const maxTitleLength = 100 - suffix.length
113
+
114
+ let title = session.title
115
+ if (title.length > maxTitleLength) {
116
+ title = title.slice(0, Math.max(0, maxTitleLength - 1)) + '…'
117
+ }
118
+
119
+ return {
120
+ name: `${title}${suffix}`,
121
+ value: session.id,
122
+ }
123
+ })
124
+
125
+ await interaction.respond(sessions)
126
+ } catch (error) {
127
+ interactionLogger.error(
128
+ '[AUTOCOMPLETE] Error fetching sessions:',
129
+ error,
130
+ )
131
+ await interaction.respond([])
132
+ }
133
+ } else if (interaction.commandName === 'session') {
134
+ const focusedOption = interaction.options.getFocused(true)
135
+
136
+ if (focusedOption.name === 'files') {
137
+ const focusedValue = focusedOption.value
138
+
139
+ const parts = focusedValue.split(',')
140
+ const previousFiles = parts
141
+ .slice(0, -1)
142
+ .map((f) => f.trim())
143
+ .filter((f) => f)
144
+ const currentQuery = (parts[parts.length - 1] || '').trim()
145
+
146
+ let projectDirectory: string | undefined
147
+ if (interaction.channel) {
148
+ const textChannel = await resolveTextChannel(
149
+ interaction.channel as TextChannel | ThreadChannel | null,
150
+ )
151
+ if (textChannel) {
152
+ const { projectDirectory: directory, channelAppId } =
153
+ getKimakiMetadata(textChannel)
154
+ if (channelAppId && channelAppId !== appId) {
155
+ await interaction.respond([])
156
+ return
157
+ }
158
+ projectDirectory = directory
159
+ }
160
+ }
161
+
162
+ if (!projectDirectory) {
163
+ await interaction.respond([])
164
+ return
165
+ }
166
+
167
+ try {
168
+ const getClient =
169
+ await initializeOpencodeForDirectory(projectDirectory)
170
+
171
+ const response = await getClient().find.files({
172
+ query: {
173
+ query: currentQuery || '',
174
+ },
175
+ })
176
+
177
+ const files = response.data || []
178
+
179
+ const prefix =
180
+ previousFiles.length > 0
181
+ ? previousFiles.join(', ') + ', '
182
+ : ''
183
+
184
+ const choices = files
185
+ .map((file: string) => {
186
+ const fullValue = prefix + file
187
+ const allFiles = [...previousFiles, file]
188
+ const allBasenames = allFiles.map(
189
+ (f) => f.split('/').pop() || f,
190
+ )
191
+ let displayName = allBasenames.join(', ')
192
+ if (displayName.length > 100) {
193
+ displayName = '…' + displayName.slice(-97)
194
+ }
195
+ return {
196
+ name: displayName,
197
+ value: fullValue,
198
+ }
199
+ })
200
+ .filter((choice) => choice.value.length <= 100)
201
+ .slice(0, 25)
202
+
203
+ await interaction.respond(choices)
204
+ } catch (error) {
205
+ interactionLogger.error('[AUTOCOMPLETE] Error fetching files:', error)
206
+ await interaction.respond([])
207
+ }
208
+ }
209
+ } else if (interaction.commandName === 'add-project') {
210
+ const focusedValue = interaction.options.getFocused()
211
+
212
+ try {
213
+ const currentDir = process.cwd()
214
+ const getClient = await initializeOpencodeForDirectory(currentDir)
215
+
216
+ const projectsResponse = await getClient().project.list({})
217
+ if (!projectsResponse.data) {
218
+ await interaction.respond([])
219
+ return
220
+ }
221
+
222
+ const db = getDatabase()
223
+ const existingDirs = db
224
+ .prepare(
225
+ 'SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?',
226
+ )
227
+ .all('text') as { directory: string }[]
228
+ const existingDirSet = new Set(
229
+ existingDirs.map((row) => row.directory),
230
+ )
231
+
232
+ const availableProjects = projectsResponse.data.filter(
233
+ (project) => !existingDirSet.has(project.worktree),
234
+ )
235
+
236
+ const projects = availableProjects
237
+ .filter((project) => {
238
+ const baseName = path.basename(project.worktree)
239
+ const searchText = `${baseName} ${project.worktree}`.toLowerCase()
240
+ return searchText.includes(focusedValue.toLowerCase())
241
+ })
242
+ .sort((a, b) => {
243
+ const aTime = a.time.initialized || a.time.created
244
+ const bTime = b.time.initialized || b.time.created
245
+ return bTime - aTime
246
+ })
247
+ .slice(0, 25)
248
+ .map((project) => {
249
+ const name = `${path.basename(project.worktree)} (${project.worktree})`
250
+ return {
251
+ name: name.length > 100 ? name.slice(0, 99) + '…' : name,
252
+ value: project.id,
253
+ }
254
+ })
255
+
256
+ await interaction.respond(projects)
257
+ } catch (error) {
258
+ interactionLogger.error(
259
+ '[AUTOCOMPLETE] Error fetching projects:',
260
+ error,
261
+ )
262
+ await interaction.respond([])
263
+ }
264
+ }
265
+ }
266
+
267
+ if (interaction.isChatInputCommand()) {
268
+ const command = interaction
269
+ interactionLogger.log(`[COMMAND] Processing: ${command.commandName}`)
270
+
271
+ if (command.commandName === 'session') {
272
+ await command.deferReply({ ephemeral: false })
273
+
274
+ const prompt = command.options.getString('prompt', true)
275
+ const filesString = command.options.getString('files') || ''
276
+ const channel = command.channel
277
+
278
+ if (!channel || channel.type !== ChannelType.GuildText) {
279
+ await command.editReply(
280
+ 'This command can only be used in text channels',
281
+ )
282
+ return
283
+ }
284
+
285
+ const textChannel = channel as TextChannel
286
+
287
+ let projectDirectory: string | undefined
288
+ let channelAppId: string | undefined
289
+
290
+ if (textChannel.topic) {
291
+ const extracted = extractTagsArrays({
292
+ xml: textChannel.topic,
293
+ tags: ['kimaki.directory', 'kimaki.app'],
294
+ })
295
+
296
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
297
+ channelAppId = extracted['kimaki.app']?.[0]?.trim()
298
+ }
299
+
300
+ if (channelAppId && channelAppId !== appId) {
301
+ await command.editReply(
302
+ 'This channel is not configured for this bot',
303
+ )
304
+ return
305
+ }
306
+
307
+ if (!projectDirectory) {
308
+ await command.editReply(
309
+ 'This channel is not configured with a project directory',
310
+ )
311
+ return
312
+ }
313
+
314
+ if (!fs.existsSync(projectDirectory)) {
315
+ await command.editReply(
316
+ `Directory does not exist: ${projectDirectory}`,
317
+ )
318
+ return
319
+ }
320
+
321
+ try {
322
+ const getClient =
323
+ await initializeOpencodeForDirectory(projectDirectory)
324
+
325
+ const files = filesString
326
+ .split(',')
327
+ .map((f) => f.trim())
328
+ .filter((f) => f)
329
+
330
+ let fullPrompt = prompt
331
+ if (files.length > 0) {
332
+ fullPrompt = `${prompt}\n\n@${files.join(' @')}`
333
+ }
334
+
335
+ const starterMessage = await textChannel.send({
336
+ content: `🚀 **Starting OpenCode session**\n📝 ${prompt.slice(0, 200)}${prompt.length > 200 ? '…' : ''}${files.length > 0 ? `\n📎 Files: ${files.join(', ')}` : ''}`,
337
+ flags: SILENT_MESSAGE_FLAGS,
338
+ })
339
+
340
+ const thread = await starterMessage.startThread({
341
+ name: prompt.slice(0, 100),
342
+ autoArchiveDuration: 1440,
343
+ reason: 'OpenCode session',
344
+ })
345
+
346
+ await command.editReply(
347
+ `Created new session in ${thread.toString()}`,
348
+ )
349
+
350
+ const parsedCommand = parseSlashCommand(fullPrompt)
351
+ await handleOpencodeSession({
352
+ prompt: fullPrompt,
353
+ thread,
354
+ projectDirectory,
355
+ parsedCommand,
356
+ channelId: textChannel.id,
357
+ })
358
+ } catch (error) {
359
+ interactionLogger.error('[SESSION] Error:', error)
360
+ await command.editReply(
361
+ `Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`,
362
+ )
363
+ }
364
+ } else if (command.commandName === 'resume') {
365
+ await command.deferReply({ ephemeral: false })
366
+
367
+ const sessionId = command.options.getString('session', true)
368
+ const channel = command.channel
369
+
370
+ if (!channel || channel.type !== ChannelType.GuildText) {
371
+ await command.editReply(
372
+ 'This command can only be used in text channels',
373
+ )
374
+ return
375
+ }
376
+
377
+ const textChannel = channel as TextChannel
378
+
379
+ let projectDirectory: string | undefined
380
+ let channelAppId: string | undefined
381
+
382
+ if (textChannel.topic) {
383
+ const extracted = extractTagsArrays({
384
+ xml: textChannel.topic,
385
+ tags: ['kimaki.directory', 'kimaki.app'],
386
+ })
387
+
388
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
389
+ channelAppId = extracted['kimaki.app']?.[0]?.trim()
390
+ }
391
+
392
+ if (channelAppId && channelAppId !== appId) {
393
+ await command.editReply(
394
+ 'This channel is not configured for this bot',
395
+ )
396
+ return
397
+ }
398
+
399
+ if (!projectDirectory) {
400
+ await command.editReply(
401
+ 'This channel is not configured with a project directory',
402
+ )
403
+ return
404
+ }
405
+
406
+ if (!fs.existsSync(projectDirectory)) {
407
+ await command.editReply(
408
+ `Directory does not exist: ${projectDirectory}`,
409
+ )
410
+ return
411
+ }
412
+
413
+ try {
414
+ const getClient =
415
+ await initializeOpencodeForDirectory(projectDirectory)
416
+
417
+ const sessionResponse = await getClient().session.get({
418
+ path: { id: sessionId },
419
+ })
420
+
421
+ if (!sessionResponse.data) {
422
+ await command.editReply('Session not found')
423
+ return
424
+ }
425
+
426
+ const sessionTitle = sessionResponse.data.title
427
+
428
+ const thread = await textChannel.threads.create({
429
+ name: `Resume: ${sessionTitle}`.slice(0, 100),
430
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
431
+ reason: `Resuming session ${sessionId}`,
432
+ })
433
+
434
+ getDatabase()
435
+ .prepare(
436
+ 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
437
+ )
438
+ .run(thread.id, sessionId)
439
+
440
+ interactionLogger.log(
441
+ `[RESUME] Created thread ${thread.id} for session ${sessionId}`,
442
+ )
443
+
444
+ const messagesResponse = await getClient().session.messages({
445
+ path: { id: sessionId },
446
+ })
447
+
448
+ if (!messagesResponse.data) {
449
+ throw new Error('Failed to fetch session messages')
450
+ }
451
+
452
+ const messages = messagesResponse.data
453
+
454
+ await command.editReply(
455
+ `Resumed session "${sessionTitle}" in ${thread.toString()}`,
456
+ )
457
+
458
+ await sendThreadMessage(
459
+ thread,
460
+ `📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
461
+ )
462
+
463
+ const allAssistantParts: { id: string; content: string }[] = []
464
+ for (const message of messages) {
465
+ if (message.info.role === 'assistant') {
466
+ for (const part of message.parts) {
467
+ const content = formatPart(part)
468
+ if (content.trim()) {
469
+ allAssistantParts.push({ id: part.id, content })
470
+ }
471
+ }
472
+ }
473
+ }
474
+
475
+ const partsToRender = allAssistantParts.slice(-30)
476
+ const skippedCount = allAssistantParts.length - partsToRender.length
477
+
478
+ if (skippedCount > 0) {
479
+ await sendThreadMessage(
480
+ thread,
481
+ `*Skipped ${skippedCount} older assistant parts...*`,
482
+ )
483
+ }
484
+
485
+ if (partsToRender.length > 0) {
486
+ const combinedContent = partsToRender
487
+ .map((p) => p.content)
488
+ .join('\n')
489
+
490
+ const discordMessage = await sendThreadMessage(
491
+ thread,
492
+ combinedContent,
493
+ )
494
+
495
+ const stmt = getDatabase().prepare(
496
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
497
+ )
498
+
499
+ const transaction = getDatabase().transaction(
500
+ (parts: { id: string }[]) => {
501
+ for (const part of parts) {
502
+ stmt.run(part.id, discordMessage.id, thread.id)
503
+ }
504
+ },
505
+ )
506
+
507
+ transaction(partsToRender)
508
+ }
509
+
510
+ const messageCount = messages.length
511
+
512
+ await sendThreadMessage(
513
+ thread,
514
+ `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
515
+ )
516
+ } catch (error) {
517
+ interactionLogger.error('[RESUME] Error:', error)
518
+ await command.editReply(
519
+ `Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`,
520
+ )
521
+ }
522
+ } else if (command.commandName === 'add-project') {
523
+ await command.deferReply({ ephemeral: false })
524
+
525
+ const projectId = command.options.getString('project', true)
526
+ const guild = command.guild
527
+
528
+ if (!guild) {
529
+ await command.editReply('This command can only be used in a guild')
530
+ return
531
+ }
532
+
533
+ try {
534
+ const currentDir = process.cwd()
535
+ const getClient = await initializeOpencodeForDirectory(currentDir)
536
+
537
+ const projectsResponse = await getClient().project.list({})
538
+ if (!projectsResponse.data) {
539
+ await command.editReply('Failed to fetch projects')
540
+ return
541
+ }
542
+
543
+ const project = projectsResponse.data.find(
544
+ (p) => p.id === projectId,
545
+ )
546
+
547
+ if (!project) {
548
+ await command.editReply('Project not found')
549
+ return
550
+ }
551
+
552
+ const directory = project.worktree
553
+
554
+ if (!fs.existsSync(directory)) {
555
+ await command.editReply(`Directory does not exist: ${directory}`)
556
+ return
557
+ }
558
+
559
+ const db = getDatabase()
560
+ const existingChannel = db
561
+ .prepare(
562
+ 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
563
+ )
564
+ .get(directory, 'text') as { channel_id: string } | undefined
565
+
566
+ if (existingChannel) {
567
+ await command.editReply(
568
+ `A channel already exists for this directory: <#${existingChannel.channel_id}>`,
569
+ )
570
+ return
571
+ }
572
+
573
+ const { textChannelId, voiceChannelId, channelName } =
574
+ await createProjectChannels({
575
+ guild,
576
+ projectDirectory: directory,
577
+ appId,
578
+ })
579
+
580
+ await command.editReply(
581
+ `✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``,
582
+ )
583
+
584
+ discordLogger.log(
585
+ `Created channels for project ${channelName} at ${directory}`,
586
+ )
587
+ } catch (error) {
588
+ interactionLogger.error('[ADD-PROJECT] Error:', error)
589
+ await command.editReply(
590
+ `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
591
+ )
592
+ }
593
+ } else if (command.commandName === 'create-new-project') {
594
+ await command.deferReply({ ephemeral: false })
595
+
596
+ const projectName = command.options.getString('name', true)
597
+ const guild = command.guild
598
+ const channel = command.channel
599
+
600
+ if (!guild) {
601
+ await command.editReply('This command can only be used in a guild')
602
+ return
603
+ }
604
+
605
+ if (!channel || channel.type !== ChannelType.GuildText) {
606
+ await command.editReply('This command can only be used in a text channel')
607
+ return
608
+ }
609
+
610
+ const sanitizedName = projectName
611
+ .toLowerCase()
612
+ .replace(/[^a-z0-9-]/g, '-')
613
+ .replace(/-+/g, '-')
614
+ .replace(/^-|-$/g, '')
615
+ .slice(0, 100)
616
+
617
+ if (!sanitizedName) {
618
+ await command.editReply('Invalid project name')
619
+ return
620
+ }
621
+
622
+ const kimakiDir = path.join(os.homedir(), 'kimaki')
623
+ const projectDirectory = path.join(kimakiDir, sanitizedName)
624
+
625
+ try {
626
+ if (!fs.existsSync(kimakiDir)) {
627
+ fs.mkdirSync(kimakiDir, { recursive: true })
628
+ discordLogger.log(`Created kimaki directory: ${kimakiDir}`)
629
+ }
630
+
631
+ if (fs.existsSync(projectDirectory)) {
632
+ await command.editReply(`Project directory already exists: ${projectDirectory}`)
633
+ return
634
+ }
635
+
636
+ fs.mkdirSync(projectDirectory, { recursive: true })
637
+ discordLogger.log(`Created project directory: ${projectDirectory}`)
638
+
639
+ const { execSync } = await import('node:child_process')
640
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
641
+ discordLogger.log(`Initialized git in: ${projectDirectory}`)
642
+
643
+ const { textChannelId, voiceChannelId, channelName } =
644
+ await createProjectChannels({
645
+ guild,
646
+ projectDirectory,
647
+ appId,
648
+ })
649
+
650
+ const textChannel = await guild.channels.fetch(textChannelId) as TextChannel
651
+
652
+ await command.editReply(
653
+ `✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`,
654
+ )
655
+
656
+ const starterMessage = await textChannel.send({
657
+ content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
658
+ flags: SILENT_MESSAGE_FLAGS,
659
+ })
660
+
661
+ const thread = await starterMessage.startThread({
662
+ name: `Init: ${sanitizedName}`,
663
+ autoArchiveDuration: 1440,
664
+ reason: 'New project session',
665
+ })
666
+
667
+ await handleOpencodeSession({
668
+ prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
669
+ thread,
670
+ projectDirectory,
671
+ channelId: textChannel.id,
672
+ })
673
+
674
+ discordLogger.log(
675
+ `Created new project ${channelName} at ${projectDirectory}`,
676
+ )
677
+ } catch (error) {
678
+ interactionLogger.error('[ADD-NEW-PROJECT] Error:', error)
679
+ await command.editReply(
680
+ `Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`,
681
+ )
682
+ }
683
+ } else if (
684
+ command.commandName === 'accept' ||
685
+ command.commandName === 'accept-always'
686
+ ) {
687
+ const scope = command.commandName === 'accept-always' ? 'always' : 'once'
688
+ const channel = command.channel
689
+
690
+ if (!channel) {
691
+ await command.reply({
692
+ content: 'This command can only be used in a channel',
693
+ ephemeral: true,
694
+ flags: SILENT_MESSAGE_FLAGS,
695
+ })
696
+ return
697
+ }
698
+
699
+ const isThread = [
700
+ ChannelType.PublicThread,
701
+ ChannelType.PrivateThread,
702
+ ChannelType.AnnouncementThread,
703
+ ].includes(channel.type)
704
+
705
+ if (!isThread) {
706
+ await command.reply({
707
+ content: 'This command can only be used in a thread with an active session',
708
+ ephemeral: true,
709
+ flags: SILENT_MESSAGE_FLAGS,
710
+ })
711
+ return
712
+ }
713
+
714
+ const pending = pendingPermissions.get(channel.id)
715
+ if (!pending) {
716
+ await command.reply({
717
+ content: 'No pending permission request in this thread',
718
+ ephemeral: true,
719
+ flags: SILENT_MESSAGE_FLAGS,
720
+ })
721
+ return
722
+ }
723
+
724
+ try {
725
+ const getClient = await initializeOpencodeForDirectory(pending.directory)
726
+ await getClient().postSessionIdPermissionsPermissionId({
727
+ path: {
728
+ id: pending.permission.sessionID,
729
+ permissionID: pending.permission.id,
730
+ },
731
+ body: {
732
+ response: scope,
733
+ },
734
+ })
735
+
736
+ pendingPermissions.delete(channel.id)
737
+ const msg =
738
+ scope === 'always'
739
+ ? `✅ Permission **accepted** (auto-approve similar requests)`
740
+ : `✅ Permission **accepted**`
741
+ await command.reply({ content: msg, flags: SILENT_MESSAGE_FLAGS })
742
+ discordLogger.log(
743
+ `Permission ${pending.permission.id} accepted with scope: ${scope}`,
744
+ )
745
+ } catch (error) {
746
+ interactionLogger.error('[ACCEPT] Error:', error)
747
+ await command.reply({
748
+ content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
749
+ ephemeral: true,
750
+ flags: SILENT_MESSAGE_FLAGS,
751
+ })
752
+ }
753
+ } else if (command.commandName === 'reject') {
754
+ const channel = command.channel
755
+
756
+ if (!channel) {
757
+ await command.reply({
758
+ content: 'This command can only be used in a channel',
759
+ ephemeral: true,
760
+ flags: SILENT_MESSAGE_FLAGS,
761
+ })
762
+ return
763
+ }
764
+
765
+ const isThread = [
766
+ ChannelType.PublicThread,
767
+ ChannelType.PrivateThread,
768
+ ChannelType.AnnouncementThread,
769
+ ].includes(channel.type)
770
+
771
+ if (!isThread) {
772
+ await command.reply({
773
+ content: 'This command can only be used in a thread with an active session',
774
+ ephemeral: true,
775
+ flags: SILENT_MESSAGE_FLAGS,
776
+ })
777
+ return
778
+ }
779
+
780
+ const pending = pendingPermissions.get(channel.id)
781
+ if (!pending) {
782
+ await command.reply({
783
+ content: 'No pending permission request in this thread',
784
+ ephemeral: true,
785
+ flags: SILENT_MESSAGE_FLAGS,
786
+ })
787
+ return
788
+ }
789
+
790
+ try {
791
+ const getClient = await initializeOpencodeForDirectory(pending.directory)
792
+ await getClient().postSessionIdPermissionsPermissionId({
793
+ path: {
794
+ id: pending.permission.sessionID,
795
+ permissionID: pending.permission.id,
796
+ },
797
+ body: {
798
+ response: 'reject',
799
+ },
800
+ })
801
+
802
+ pendingPermissions.delete(channel.id)
803
+ await command.reply({ content: `❌ Permission **rejected**`, flags: SILENT_MESSAGE_FLAGS })
804
+ discordLogger.log(`Permission ${pending.permission.id} rejected`)
805
+ } catch (error) {
806
+ interactionLogger.error('[REJECT] Error:', error)
807
+ await command.reply({
808
+ content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
809
+ ephemeral: true,
810
+ flags: SILENT_MESSAGE_FLAGS,
811
+ })
812
+ }
813
+ } else if (command.commandName === 'abort') {
814
+ const channel = command.channel
815
+
816
+ if (!channel) {
817
+ await command.reply({
818
+ content: 'This command can only be used in a channel',
819
+ ephemeral: true,
820
+ flags: SILENT_MESSAGE_FLAGS,
821
+ })
822
+ return
823
+ }
824
+
825
+ const isThread = [
826
+ ChannelType.PublicThread,
827
+ ChannelType.PrivateThread,
828
+ ChannelType.AnnouncementThread,
829
+ ].includes(channel.type)
830
+
831
+ if (!isThread) {
832
+ await command.reply({
833
+ content: 'This command can only be used in a thread with an active session',
834
+ ephemeral: true,
835
+ flags: SILENT_MESSAGE_FLAGS,
836
+ })
837
+ return
838
+ }
839
+
840
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
841
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
842
+
843
+ if (!directory) {
844
+ await command.reply({
845
+ content: 'Could not determine project directory for this channel',
846
+ ephemeral: true,
847
+ flags: SILENT_MESSAGE_FLAGS,
848
+ })
849
+ return
850
+ }
851
+
852
+ const row = getDatabase()
853
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
854
+ .get(channel.id) as { session_id: string } | undefined
855
+
856
+ if (!row?.session_id) {
857
+ await command.reply({
858
+ content: 'No active session in this thread',
859
+ ephemeral: true,
860
+ flags: SILENT_MESSAGE_FLAGS,
861
+ })
862
+ return
863
+ }
864
+
865
+ const sessionId = row.session_id
866
+
867
+ try {
868
+ const existingController = abortControllers.get(sessionId)
869
+ if (existingController) {
870
+ existingController.abort(new Error('User requested abort'))
871
+ abortControllers.delete(sessionId)
872
+ }
873
+
874
+ const getClient = await initializeOpencodeForDirectory(directory)
875
+ await getClient().session.abort({
876
+ path: { id: sessionId },
877
+ })
878
+
879
+ await command.reply({ content: `🛑 Request **aborted**`, flags: SILENT_MESSAGE_FLAGS })
880
+ discordLogger.log(`Session ${sessionId} aborted by user`)
881
+ } catch (error) {
882
+ interactionLogger.error('[ABORT] Error:', error)
883
+ await command.reply({
884
+ content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
885
+ ephemeral: true,
886
+ flags: SILENT_MESSAGE_FLAGS,
887
+ })
888
+ }
889
+ } else if (command.commandName === 'share') {
890
+ const channel = command.channel
891
+
892
+ if (!channel) {
893
+ await command.reply({
894
+ content: 'This command can only be used in a channel',
895
+ ephemeral: true,
896
+ flags: SILENT_MESSAGE_FLAGS,
897
+ })
898
+ return
899
+ }
900
+
901
+ const isThread = [
902
+ ChannelType.PublicThread,
903
+ ChannelType.PrivateThread,
904
+ ChannelType.AnnouncementThread,
905
+ ].includes(channel.type)
906
+
907
+ if (!isThread) {
908
+ await command.reply({
909
+ content: 'This command can only be used in a thread with an active session',
910
+ ephemeral: true,
911
+ flags: SILENT_MESSAGE_FLAGS,
912
+ })
913
+ return
914
+ }
915
+
916
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
917
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
918
+
919
+ if (!directory) {
920
+ await command.reply({
921
+ content: 'Could not determine project directory for this channel',
922
+ ephemeral: true,
923
+ flags: SILENT_MESSAGE_FLAGS,
924
+ })
925
+ return
926
+ }
927
+
928
+ const row = getDatabase()
929
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
930
+ .get(channel.id) as { session_id: string } | undefined
931
+
932
+ if (!row?.session_id) {
933
+ await command.reply({
934
+ content: 'No active session in this thread',
935
+ ephemeral: true,
936
+ flags: SILENT_MESSAGE_FLAGS,
937
+ })
938
+ return
939
+ }
940
+
941
+ const sessionId = row.session_id
942
+
943
+ try {
944
+ const getClient = await initializeOpencodeForDirectory(directory)
945
+ const response = await getClient().session.share({
946
+ path: { id: sessionId },
947
+ })
948
+
949
+ if (!response.data?.share?.url) {
950
+ await command.reply({
951
+ content: 'Failed to generate share URL',
952
+ ephemeral: true,
953
+ flags: SILENT_MESSAGE_FLAGS,
954
+ })
955
+ return
956
+ }
957
+
958
+ await command.reply({ content: `🔗 **Session shared:** ${response.data.share.url}`, flags: SILENT_MESSAGE_FLAGS })
959
+ discordLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`)
960
+ } catch (error) {
961
+ interactionLogger.error('[SHARE] Error:', error)
962
+ await command.reply({
963
+ content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
964
+ ephemeral: true,
965
+ flags: SILENT_MESSAGE_FLAGS,
966
+ })
967
+ }
968
+ } else if (command.commandName === 'fork') {
969
+ await handleForkCommand(command)
970
+ } else if (command.commandName === 'model') {
971
+ await handleModelCommand({ interaction: command, appId })
972
+ }
973
+ }
974
+
975
+ if (interaction.isStringSelectMenu()) {
976
+ if (interaction.customId.startsWith('fork_select:')) {
977
+ await handleForkSelectMenu(interaction)
978
+ } else if (interaction.customId.startsWith('model_provider:')) {
979
+ await handleProviderSelectMenu(interaction)
980
+ } else if (interaction.customId.startsWith('model_select:')) {
981
+ await handleModelSelectMenu(interaction)
982
+ }
983
+ }
984
+ } catch (error) {
985
+ interactionLogger.error('[INTERACTION] Error handling interaction:', error)
986
+ // Try to respond to the interaction if possible
987
+ try {
988
+ if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) {
989
+ await interaction.reply({
990
+ content: 'An error occurred processing this command.',
991
+ ephemeral: true,
992
+ })
993
+ }
994
+ } catch (replyError) {
995
+ interactionLogger.error('[INTERACTION] Failed to send error reply:', replyError)
996
+ }
997
+ }
998
+ },
999
+ )
1000
+ }