kimaki 0.4.25 → 0.4.27

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 (52) hide show
  1. package/dist/acp-client.test.js +149 -0
  2. package/dist/channel-management.js +11 -9
  3. package/dist/cli.js +58 -18
  4. package/dist/commands/add-project.js +1 -0
  5. package/dist/commands/agent.js +152 -0
  6. package/dist/commands/ask-question.js +184 -0
  7. package/dist/commands/model.js +23 -4
  8. package/dist/commands/permissions.js +101 -105
  9. package/dist/commands/session.js +1 -3
  10. package/dist/commands/user-command.js +145 -0
  11. package/dist/database.js +51 -0
  12. package/dist/discord-bot.js +32 -32
  13. package/dist/discord-utils.js +71 -14
  14. package/dist/interaction-handler.js +25 -8
  15. package/dist/logger.js +43 -5
  16. package/dist/markdown.js +104 -0
  17. package/dist/markdown.test.js +31 -1
  18. package/dist/message-formatting.js +72 -22
  19. package/dist/message-formatting.test.js +73 -0
  20. package/dist/opencode.js +70 -16
  21. package/dist/session-handler.js +142 -66
  22. package/dist/system-message.js +4 -51
  23. package/dist/voice-handler.js +18 -8
  24. package/dist/voice.js +28 -12
  25. package/package.json +14 -13
  26. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  27. package/src/__snapshots__/compact-session-context.md +47 -0
  28. package/src/channel-management.ts +20 -8
  29. package/src/cli.ts +73 -19
  30. package/src/commands/add-project.ts +1 -0
  31. package/src/commands/agent.ts +201 -0
  32. package/src/commands/ask-question.ts +277 -0
  33. package/src/commands/fork.ts +1 -2
  34. package/src/commands/model.ts +24 -4
  35. package/src/commands/permissions.ts +139 -114
  36. package/src/commands/session.ts +1 -3
  37. package/src/commands/user-command.ts +178 -0
  38. package/src/database.ts +61 -0
  39. package/src/discord-bot.ts +36 -33
  40. package/src/discord-utils.ts +76 -14
  41. package/src/interaction-handler.ts +31 -10
  42. package/src/logger.ts +47 -10
  43. package/src/markdown.test.ts +45 -1
  44. package/src/markdown.ts +132 -0
  45. package/src/message-formatting.test.ts +81 -0
  46. package/src/message-formatting.ts +93 -25
  47. package/src/opencode.ts +80 -21
  48. package/src/session-handler.ts +190 -97
  49. package/src/system-message.ts +4 -51
  50. package/src/voice-handler.ts +20 -9
  51. package/src/voice.ts +32 -13
  52. package/LICENSE +0 -21
@@ -0,0 +1,277 @@
1
+ // AskUserQuestion tool handler - Shows Discord dropdowns for AI questions.
2
+ // When the AI uses the AskUserQuestion tool, this module renders dropdowns
3
+ // for each question and collects user responses.
4
+
5
+ import {
6
+ StringSelectMenuBuilder,
7
+ StringSelectMenuInteraction,
8
+ ActionRowBuilder,
9
+ type ThreadChannel,
10
+ } from 'discord.js'
11
+ import crypto from 'node:crypto'
12
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js'
13
+ import { getOpencodeServerPort } from '../opencode.js'
14
+ import { createLogger } from '../logger.js'
15
+
16
+ const logger = createLogger('ASK_QUESTION')
17
+
18
+ // Schema matching the question tool input
19
+ export type AskUserQuestionInput = {
20
+ questions: Array<{
21
+ question: string
22
+ header: string // max 12 chars
23
+ options: Array<{
24
+ label: string
25
+ description: string
26
+ }>
27
+ multiple?: boolean // optional, defaults to false
28
+ }>
29
+ }
30
+
31
+ type PendingQuestionContext = {
32
+ sessionId: string
33
+ directory: string
34
+ thread: ThreadChannel
35
+ requestId: string // OpenCode question request ID for replying
36
+ questions: AskUserQuestionInput['questions']
37
+ answers: Record<number, string[]> // questionIndex -> selected labels
38
+ totalQuestions: number
39
+ answeredCount: number
40
+ contextHash: string
41
+ }
42
+
43
+ // Store pending question contexts by hash
44
+ export const pendingQuestionContexts = new Map<string, PendingQuestionContext>()
45
+
46
+ /**
47
+ * Show dropdown menus for question tool input.
48
+ * Sends one message per question with the dropdown directly under the question text.
49
+ */
50
+ export async function showAskUserQuestionDropdowns({
51
+ thread,
52
+ sessionId,
53
+ directory,
54
+ requestId,
55
+ input,
56
+ }: {
57
+ thread: ThreadChannel
58
+ sessionId: string
59
+ directory: string
60
+ requestId: string // OpenCode question request ID
61
+ input: AskUserQuestionInput
62
+ }): Promise<void> {
63
+ const contextHash = crypto.randomBytes(8).toString('hex')
64
+
65
+ const context: PendingQuestionContext = {
66
+ sessionId,
67
+ directory,
68
+ thread,
69
+ requestId,
70
+ questions: input.questions,
71
+ answers: {},
72
+ totalQuestions: input.questions.length,
73
+ answeredCount: 0,
74
+ contextHash,
75
+ }
76
+
77
+ pendingQuestionContexts.set(contextHash, context)
78
+
79
+ // Send one message per question with its dropdown directly underneath
80
+ for (let i = 0; i < input.questions.length; i++) {
81
+ const q = input.questions[i]!
82
+
83
+ // Map options to Discord select menu options
84
+ // Discord max: 25 options per select menu
85
+ const options = [
86
+ ...q.options.slice(0, 24).map((opt, optIdx) => ({
87
+ label: opt.label.slice(0, 100),
88
+ value: `${optIdx}`,
89
+ description: opt.description.slice(0, 100),
90
+ })),
91
+ {
92
+ label: 'Other',
93
+ value: 'other',
94
+ description: 'Provide a custom answer in chat',
95
+ },
96
+ ]
97
+
98
+ const selectMenu = new StringSelectMenuBuilder()
99
+ .setCustomId(`ask_question:${contextHash}:${i}`)
100
+ .setPlaceholder(`Select an option`)
101
+ .addOptions(options)
102
+
103
+ // Enable multi-select if the question supports it
104
+ if (q.multiple) {
105
+ selectMenu.setMinValues(1)
106
+ selectMenu.setMaxValues(options.length)
107
+ }
108
+
109
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
110
+
111
+ await thread.send({
112
+ content: `**${q.header}**\n${q.question}`,
113
+ components: [actionRow],
114
+ flags: NOTIFY_MESSAGE_FLAGS,
115
+ })
116
+ }
117
+
118
+ logger.log(`Showed ${input.questions.length} question dropdown(s) for session ${sessionId}`)
119
+ }
120
+
121
+ /**
122
+ * Handle dropdown selection for AskUserQuestion.
123
+ */
124
+ export async function handleAskQuestionSelectMenu(
125
+ interaction: StringSelectMenuInteraction
126
+ ): Promise<void> {
127
+ const customId = interaction.customId
128
+
129
+ if (!customId.startsWith('ask_question:')) {
130
+ return
131
+ }
132
+
133
+ const parts = customId.split(':')
134
+ const contextHash = parts[1]
135
+ const questionIndex = parseInt(parts[2]!, 10)
136
+
137
+ if (!contextHash) {
138
+ await interaction.reply({
139
+ content: 'Invalid selection.',
140
+ ephemeral: true,
141
+ })
142
+ return
143
+ }
144
+
145
+ const context = pendingQuestionContexts.get(contextHash)
146
+
147
+ if (!context) {
148
+ await interaction.reply({
149
+ content: 'This question has expired. Please ask the AI again.',
150
+ ephemeral: true,
151
+ })
152
+ return
153
+ }
154
+
155
+ await interaction.deferUpdate()
156
+
157
+ const selectedValues = interaction.values
158
+ const question = context.questions[questionIndex]
159
+
160
+ if (!question) {
161
+ logger.error(`Question index ${questionIndex} not found in context`)
162
+ return
163
+ }
164
+
165
+ // Check if "other" was selected
166
+ if (selectedValues.includes('other')) {
167
+ // User wants to provide custom answer
168
+ // For now, mark as "Other" - they can type in chat
169
+ context.answers[questionIndex] = ['Other (please type your answer in chat)']
170
+ } else {
171
+ // Map value indices back to option labels
172
+ context.answers[questionIndex] = selectedValues.map((v) => {
173
+ const optIdx = parseInt(v, 10)
174
+ return question.options[optIdx]?.label || `Option ${optIdx + 1}`
175
+ })
176
+ }
177
+
178
+ context.answeredCount++
179
+
180
+ // Update this question's message: show answer and remove dropdown
181
+ const answeredText = context.answers[questionIndex]!.join(', ')
182
+ await interaction.editReply({
183
+ content: `**${question.header}**\n${question.question}\n✓ _${answeredText}_`,
184
+ components: [], // Remove the dropdown
185
+ })
186
+
187
+ // Check if all questions are answered
188
+ if (context.answeredCount >= context.totalQuestions) {
189
+ // All questions answered - send result back to session
190
+ await submitQuestionAnswers(context)
191
+ pendingQuestionContexts.delete(contextHash)
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Submit all collected answers back to the OpenCode session.
197
+ * Uses the question.reply API to provide answers to the waiting tool.
198
+ */
199
+ async function submitQuestionAnswers(
200
+ context: PendingQuestionContext
201
+ ): Promise<void> {
202
+ try {
203
+ // Build answers array: each element is an array of selected labels for that question
204
+ const answersPayload = context.questions.map((_, i) => {
205
+ return context.answers[i] || []
206
+ })
207
+
208
+ // Reply to the question using direct HTTP call to OpenCode API
209
+ // (v1 SDK doesn't have question.reply, so we call it directly)
210
+ const port = getOpencodeServerPort(context.directory)
211
+ if (!port) {
212
+ throw new Error('OpenCode server not found for directory')
213
+ }
214
+
215
+ const response = await fetch(
216
+ `http://127.0.0.1:${port}/question/${context.requestId}/reply`,
217
+ {
218
+ method: 'POST',
219
+ headers: { 'Content-Type': 'application/json' },
220
+ body: JSON.stringify({ answers: answersPayload }),
221
+ }
222
+ )
223
+
224
+ if (!response.ok) {
225
+ const text = await response.text()
226
+ throw new Error(`Failed to reply to question: ${response.status} ${text}`)
227
+ }
228
+
229
+ logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`)
230
+ } catch (error) {
231
+ logger.error('Failed to submit answers:', error)
232
+ await sendThreadMessage(
233
+ context.thread,
234
+ `✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`
235
+ )
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Check if a tool part is an AskUserQuestion tool.
241
+ * Returns the parsed input if valid, null otherwise.
242
+ */
243
+ export function parseAskUserQuestionTool(part: {
244
+ type: string
245
+ tool?: string
246
+ state?: { input?: unknown }
247
+ }): AskUserQuestionInput | null {
248
+ if (part.type !== 'tool') {
249
+ return null
250
+ }
251
+
252
+ // Check for the tool name (case-insensitive)
253
+ const toolName = part.tool?.toLowerCase()
254
+ if (toolName !== 'question') {
255
+ return null
256
+ }
257
+
258
+ const input = part.state?.input as AskUserQuestionInput | undefined
259
+
260
+ if (!input?.questions || !Array.isArray(input.questions) || input.questions.length === 0) {
261
+ return null
262
+ }
263
+
264
+ // Validate structure
265
+ for (const q of input.questions) {
266
+ if (
267
+ typeof q.question !== 'string' ||
268
+ typeof q.header !== 'string' ||
269
+ !Array.isArray(q.options) ||
270
+ q.options.length < 2
271
+ ) {
272
+ return null
273
+ }
274
+ }
275
+
276
+ return input
277
+ }
@@ -9,7 +9,6 @@ import {
9
9
  ThreadAutoArchiveDuration,
10
10
  type ThreadChannel,
11
11
  } from 'discord.js'
12
- import type { TextPart } from '@opencode-ai/sdk'
13
12
  import { getDatabase } from '../database.js'
14
13
  import { initializeOpencodeForDirectory } from '../opencode.js'
15
14
  import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js'
@@ -100,7 +99,7 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
100
99
  const recentMessages = userMessages.slice(-25)
101
100
 
102
101
  const options = recentMessages.map((m, index) => {
103
- const textPart = m.parts.find((p) => p.type === 'text') as TextPart | undefined
102
+ const textPart = m.parts.find((p) => p.type === 'text') as { type: 'text'; text: string } | undefined
104
103
  const preview = textPart?.text?.slice(0, 80) || '(no text)'
105
104
  const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`
106
105
 
@@ -13,6 +13,7 @@ import crypto from 'node:crypto'
13
13
  import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from '../database.js'
14
14
  import { initializeOpencodeForDirectory } from '../opencode.js'
15
15
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
16
+ import { abortAndRetrySession } from '../session-handler.js'
16
17
  import { createLogger } from '../logger.js'
17
18
 
18
19
  const modelLogger = createLogger('MODEL')
@@ -25,6 +26,7 @@ const pendingModelContexts = new Map<string, {
25
26
  isThread: boolean
26
27
  providerId?: string
27
28
  providerName?: string
29
+ thread?: ThreadChannel
28
30
  }>()
29
31
 
30
32
  export type ProviderInfo = {
@@ -156,6 +158,7 @@ export async function handleModelCommand({
156
158
  channelId: targetChannelId,
157
159
  sessionId: sessionId,
158
160
  isThread: isThread,
161
+ thread: isThread ? (channel as ThreadChannel) : undefined,
159
162
  }
160
163
  const contextHash = crypto.randomBytes(8).toString('hex')
161
164
  pendingModelContexts.set(contextHash, context)
@@ -355,10 +358,27 @@ export async function handleModelSelectMenu(
355
358
  setSessionModel(context.sessionId, fullModelId)
356
359
  modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`)
357
360
 
358
- await interaction.editReply({
359
- content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
360
- components: [],
361
- })
361
+ // Check if there's a running request and abort+retry with new model
362
+ let retried = false
363
+ if (context.thread) {
364
+ retried = await abortAndRetrySession({
365
+ sessionId: context.sessionId,
366
+ thread: context.thread,
367
+ projectDirectory: context.dir,
368
+ })
369
+ }
370
+
371
+ if (retried) {
372
+ await interaction.editReply({
373
+ content: `Model changed for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\n_Retrying current request with new model..._`,
374
+ components: [],
375
+ })
376
+ } else {
377
+ await interaction.editReply({
378
+ content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
379
+ components: [],
380
+ })
381
+ }
362
382
  } else {
363
383
  // Store for channel
364
384
  setChannelModel(context.channelId, fullModelId)
@@ -1,146 +1,171 @@
1
- // Permission commands - /accept, /accept-always, /reject
2
-
3
- import { ChannelType } from 'discord.js'
4
- import type { CommandContext } from './types.js'
1
+ // Permission dropdown handler - Shows dropdown for permission requests.
2
+ // When OpenCode asks for permission, this module renders a dropdown
3
+ // with Accept, Accept Always, and Deny options.
4
+
5
+ import {
6
+ StringSelectMenuBuilder,
7
+ StringSelectMenuInteraction,
8
+ ActionRowBuilder,
9
+ type ThreadChannel,
10
+ } from 'discord.js'
11
+ import crypto from 'node:crypto'
12
+ import type { PermissionRequest } from '@opencode-ai/sdk/v2'
5
13
  import { initializeOpencodeForDirectory } from '../opencode.js'
6
- import { pendingPermissions } from '../session-handler.js'
7
- import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
14
+ import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js'
8
15
  import { createLogger } from '../logger.js'
9
16
 
10
17
  const logger = createLogger('PERMISSIONS')
11
18
 
12
- export async function handleAcceptCommand({
13
- command,
14
- }: CommandContext): Promise<void> {
15
- const scope = command.commandName === 'accept-always' ? 'always' : 'once'
16
- const channel = command.channel
17
-
18
- if (!channel) {
19
- await command.reply({
20
- content: 'This command can only be used in a channel',
21
- ephemeral: true,
22
- flags: SILENT_MESSAGE_FLAGS,
23
- })
24
- return
25
- }
26
-
27
- const isThread = [
28
- ChannelType.PublicThread,
29
- ChannelType.PrivateThread,
30
- ChannelType.AnnouncementThread,
31
- ].includes(channel.type)
32
-
33
- if (!isThread) {
34
- await command.reply({
35
- content: 'This command can only be used in a thread with an active session',
36
- ephemeral: true,
37
- flags: SILENT_MESSAGE_FLAGS,
38
- })
39
- return
40
- }
19
+ type PendingPermissionContext = {
20
+ permission: PermissionRequest
21
+ directory: string
22
+ thread: ThreadChannel
23
+ contextHash: string
24
+ }
41
25
 
42
- const pending = pendingPermissions.get(channel.id)
43
- if (!pending) {
44
- await command.reply({
45
- content: 'No pending permission request in this thread',
46
- ephemeral: true,
47
- flags: SILENT_MESSAGE_FLAGS,
48
- })
49
- return
26
+ // Store pending permission contexts by hash
27
+ export const pendingPermissionContexts = new Map<string, PendingPermissionContext>()
28
+
29
+ /**
30
+ * Show permission dropdown for a permission request.
31
+ * Returns the message ID and context hash for tracking.
32
+ */
33
+ export async function showPermissionDropdown({
34
+ thread,
35
+ permission,
36
+ directory,
37
+ }: {
38
+ thread: ThreadChannel
39
+ permission: PermissionRequest
40
+ directory: string
41
+ }): Promise<{ messageId: string; contextHash: string }> {
42
+ const contextHash = crypto.randomBytes(8).toString('hex')
43
+
44
+ const context: PendingPermissionContext = {
45
+ permission,
46
+ directory,
47
+ thread,
48
+ contextHash,
50
49
  }
51
50
 
52
- try {
53
- const getClient = await initializeOpencodeForDirectory(pending.directory)
54
- await getClient().postSessionIdPermissionsPermissionId({
55
- path: {
56
- id: pending.permission.sessionID,
57
- permissionID: pending.permission.id,
58
- },
59
- body: {
60
- response: scope,
61
- },
62
- })
63
-
64
- pendingPermissions.delete(channel.id)
65
- const msg =
66
- scope === 'always'
67
- ? `✅ Permission **accepted** (auto-approve similar requests)`
68
- : `✅ Permission **accepted**`
69
- await command.reply({ content: msg, flags: SILENT_MESSAGE_FLAGS })
70
- logger.log(`Permission ${pending.permission.id} accepted with scope: ${scope}`)
71
- } catch (error) {
72
- logger.error('[ACCEPT] Error:', error)
73
- await command.reply({
74
- content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
75
- ephemeral: true,
76
- flags: SILENT_MESSAGE_FLAGS,
77
- })
78
- }
51
+ pendingPermissionContexts.set(contextHash, context)
52
+
53
+ const patternStr = permission.patterns.join(', ')
54
+
55
+ // Build dropdown options
56
+ const options = [
57
+ {
58
+ label: 'Accept',
59
+ value: 'once',
60
+ description: 'Allow this request only',
61
+ },
62
+ {
63
+ label: 'Accept Always',
64
+ value: 'always',
65
+ description: 'Auto-approve similar requests',
66
+ },
67
+ {
68
+ label: 'Deny',
69
+ value: 'reject',
70
+ description: 'Reject this permission request',
71
+ },
72
+ ]
73
+
74
+ const selectMenu = new StringSelectMenuBuilder()
75
+ .setCustomId(`permission:${contextHash}`)
76
+ .setPlaceholder('Choose an action')
77
+ .addOptions(options)
78
+
79
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
80
+
81
+ const permissionMessage = await thread.send({
82
+ content:
83
+ `⚠️ **Permission Required**\n\n` +
84
+ `**Type:** \`${permission.permission}\`\n` +
85
+ (patternStr ? `**Pattern:** \`${patternStr}\`` : ''),
86
+ components: [actionRow],
87
+ flags: NOTIFY_MESSAGE_FLAGS,
88
+ })
89
+
90
+ logger.log(`Showed permission dropdown for ${permission.id}`)
91
+
92
+ return { messageId: permissionMessage.id, contextHash }
79
93
  }
80
94
 
81
- export async function handleRejectCommand({
82
- command,
83
- }: CommandContext): Promise<void> {
84
- const channel = command.channel
95
+ /**
96
+ * Handle dropdown selection for permission.
97
+ */
98
+ export async function handlePermissionSelectMenu(
99
+ interaction: StringSelectMenuInteraction
100
+ ): Promise<void> {
101
+ const customId = interaction.customId
85
102
 
86
- if (!channel) {
87
- await command.reply({
88
- content: 'This command can only be used in a channel',
89
- ephemeral: true,
90
- flags: SILENT_MESSAGE_FLAGS,
91
- })
103
+ if (!customId.startsWith('permission:')) {
92
104
  return
93
105
  }
94
106
 
95
- const isThread = [
96
- ChannelType.PublicThread,
97
- ChannelType.PrivateThread,
98
- ChannelType.AnnouncementThread,
99
- ].includes(channel.type)
107
+ const contextHash = customId.replace('permission:', '')
108
+ const context = pendingPermissionContexts.get(contextHash)
100
109
 
101
- if (!isThread) {
102
- await command.reply({
103
- content: 'This command can only be used in a thread with an active session',
110
+ if (!context) {
111
+ await interaction.reply({
112
+ content: 'This permission request has expired or was already handled.',
104
113
  ephemeral: true,
105
- flags: SILENT_MESSAGE_FLAGS,
106
114
  })
107
115
  return
108
116
  }
109
117
 
110
- const pending = pendingPermissions.get(channel.id)
111
- if (!pending) {
112
- await command.reply({
113
- content: 'No pending permission request in this thread',
114
- ephemeral: true,
115
- flags: SILENT_MESSAGE_FLAGS,
116
- })
117
- return
118
- }
118
+ await interaction.deferUpdate()
119
+
120
+ const response = interaction.values[0] as 'once' | 'always' | 'reject'
119
121
 
120
122
  try {
121
- const getClient = await initializeOpencodeForDirectory(pending.directory)
123
+ const getClient = await initializeOpencodeForDirectory(context.directory)
122
124
  await getClient().postSessionIdPermissionsPermissionId({
123
125
  path: {
124
- id: pending.permission.sessionID,
125
- permissionID: pending.permission.id,
126
- },
127
- body: {
128
- response: 'reject',
126
+ id: context.permission.sessionID,
127
+ permissionID: context.permission.id,
129
128
  },
129
+ body: { response },
130
130
  })
131
131
 
132
- pendingPermissions.delete(channel.id)
133
- await command.reply({
134
- content: `❌ Permission **rejected**`,
135
- flags: SILENT_MESSAGE_FLAGS,
132
+ pendingPermissionContexts.delete(contextHash)
133
+
134
+ // Update message: show result and remove dropdown
135
+ const resultText = (() => {
136
+ switch (response) {
137
+ case 'once':
138
+ return '✅ Permission **accepted**'
139
+ case 'always':
140
+ return '✅ Permission **accepted** (auto-approve similar requests)'
141
+ case 'reject':
142
+ return '❌ Permission **rejected**'
143
+ }
144
+ })()
145
+
146
+ const patternStr = context.permission.patterns.join(', ')
147
+ await interaction.editReply({
148
+ content:
149
+ `⚠️ **Permission Required**\n\n` +
150
+ `**Type:** \`${context.permission.permission}\`\n` +
151
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n\n` : '\n') +
152
+ resultText,
153
+ components: [], // Remove the dropdown
136
154
  })
137
- logger.log(`Permission ${pending.permission.id} rejected`)
155
+
156
+ logger.log(`Permission ${context.permission.id} ${response}`)
138
157
  } catch (error) {
139
- logger.error('[REJECT] Error:', error)
140
- await command.reply({
141
- content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
142
- ephemeral: true,
143
- flags: SILENT_MESSAGE_FLAGS,
158
+ logger.error('Error handling permission:', error)
159
+ await interaction.editReply({
160
+ content: `Failed to process permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
161
+ components: [],
144
162
  })
145
163
  }
146
164
  }
165
+
166
+ /**
167
+ * Clean up a pending permission context (e.g., on auto-reject).
168
+ */
169
+ export function cleanupPermissionContext(contextHash: string): void {
170
+ pendingPermissionContexts.delete(contextHash)
171
+ }
@@ -8,7 +8,7 @@ import { getDatabase } from '../database.js'
8
8
  import { initializeOpencodeForDirectory } from '../opencode.js'
9
9
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
10
10
  import { extractTagsArrays } from '../xml.js'
11
- import { handleOpencodeSession, parseSlashCommand } from '../session-handler.js'
11
+ import { handleOpencodeSession } from '../session-handler.js'
12
12
  import { createLogger } from '../logger.js'
13
13
 
14
14
  const logger = createLogger('SESSION')
@@ -86,12 +86,10 @@ export async function handleSessionCommand({
86
86
 
87
87
  await command.editReply(`Created new session in ${thread.toString()}`)
88
88
 
89
- const parsedCommand = parseSlashCommand(fullPrompt)
90
89
  await handleOpencodeSession({
91
90
  prompt: fullPrompt,
92
91
  thread,
93
92
  projectDirectory,
94
- parsedCommand,
95
93
  channelId: textChannel.id,
96
94
  })
97
95
  } catch (error) {