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.
- package/dist/acp-client.test.js +149 -0
- package/dist/channel-management.js +11 -9
- package/dist/cli.js +58 -18
- package/dist/commands/add-project.js +1 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +184 -0
- package/dist/commands/model.js +23 -4
- package/dist/commands/permissions.js +101 -105
- package/dist/commands/session.js +1 -3
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +51 -0
- package/dist/discord-bot.js +32 -32
- package/dist/discord-utils.js +71 -14
- package/dist/interaction-handler.js +25 -8
- package/dist/logger.js +43 -5
- package/dist/markdown.js +104 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +72 -22
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +70 -16
- package/dist/session-handler.js +142 -66
- package/dist/system-message.js +4 -51
- package/dist/voice-handler.js +18 -8
- package/dist/voice.js +28 -12
- package/package.json +14 -13
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/channel-management.ts +20 -8
- package/src/cli.ts +73 -19
- package/src/commands/add-project.ts +1 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +277 -0
- package/src/commands/fork.ts +1 -2
- package/src/commands/model.ts +24 -4
- package/src/commands/permissions.ts +139 -114
- package/src/commands/session.ts +1 -3
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +61 -0
- package/src/discord-bot.ts +36 -33
- package/src/discord-utils.ts +76 -14
- package/src/interaction-handler.ts +31 -10
- package/src/logger.ts +47 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +132 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +93 -25
- package/src/opencode.ts +80 -21
- package/src/session-handler.ts +190 -97
- package/src/system-message.ts +4 -51
- package/src/voice-handler.ts +20 -9
- package/src/voice.ts +32 -13
- 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
|
+
}
|
package/src/commands/fork.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/commands/model.ts
CHANGED
|
@@ -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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 (!
|
|
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
|
|
96
|
-
|
|
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 (!
|
|
102
|
-
await
|
|
103
|
-
content: 'This
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
123
|
+
const getClient = await initializeOpencodeForDirectory(context.directory)
|
|
122
124
|
await getClient().postSessionIdPermissionsPermissionId({
|
|
123
125
|
path: {
|
|
124
|
-
id:
|
|
125
|
-
permissionID:
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
155
|
+
|
|
156
|
+
logger.log(`Permission ${context.permission.id} ${response}`)
|
|
138
157
|
} catch (error) {
|
|
139
|
-
logger.error('
|
|
140
|
-
await
|
|
141
|
-
content: `Failed to
|
|
142
|
-
|
|
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
|
+
}
|
package/src/commands/session.ts
CHANGED
|
@@ -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
|
|
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) {
|