kimaki 0.4.26 → 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/cli.js CHANGED
@@ -156,18 +156,6 @@ async function registerCommands(token, appId, userCommands = []) {
156
156
  return option;
157
157
  })
158
158
  .toJSON(),
159
- new SlashCommandBuilder()
160
- .setName('accept')
161
- .setDescription('Accept a pending permission request (this request only)')
162
- .toJSON(),
163
- new SlashCommandBuilder()
164
- .setName('accept-always')
165
- .setDescription('Accept and auto-approve future requests matching this pattern')
166
- .toJSON(),
167
- new SlashCommandBuilder()
168
- .setName('reject')
169
- .setDescription('Reject a pending permission request')
170
- .toJSON(),
171
159
  new SlashCommandBuilder()
172
160
  .setName('abort')
173
161
  .setDescription('Abort the current OpenCode request in this thread')
@@ -3,7 +3,7 @@
3
3
  // for each question and collects user responses.
4
4
  import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder, } from 'discord.js';
5
5
  import crypto from 'node:crypto';
6
- import { sendThreadMessage } from '../discord-utils.js';
6
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
7
7
  import { getOpencodeServerPort } from '../opencode.js';
8
8
  import { createLogger } from '../logger.js';
9
9
  const logger = createLogger('ASK_QUESTION');
@@ -57,6 +57,7 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
57
57
  await thread.send({
58
58
  content: `**${q.header}**\n${q.question}`,
59
59
  components: [actionRow],
60
+ flags: NOTIFY_MESSAGE_FLAGS,
60
61
  });
61
62
  }
62
63
  logger.log(`Showed ${input.questions.length} question dropdown(s) for session ${sessionId}`);
@@ -1,126 +1,122 @@
1
- // Permission commands - /accept, /accept-always, /reject
2
- import { ChannelType } from 'discord.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
+ import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder, } from 'discord.js';
5
+ import crypto from 'node:crypto';
3
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
4
- import { pendingPermissions } from '../session-handler.js';
5
- import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
7
+ import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
6
8
  import { createLogger } from '../logger.js';
7
9
  const logger = createLogger('PERMISSIONS');
8
- export async function handleAcceptCommand({ command, }) {
9
- const scope = command.commandName === 'accept-always' ? 'always' : 'once';
10
- const channel = command.channel;
11
- if (!channel) {
12
- await command.reply({
13
- content: 'This command can only be used in a channel',
14
- ephemeral: true,
15
- flags: SILENT_MESSAGE_FLAGS,
16
- });
17
- return;
18
- }
19
- const isThread = [
20
- ChannelType.PublicThread,
21
- ChannelType.PrivateThread,
22
- ChannelType.AnnouncementThread,
23
- ].includes(channel.type);
24
- if (!isThread) {
25
- await command.reply({
26
- content: 'This command can only be used in a thread with an active session',
27
- ephemeral: true,
28
- flags: SILENT_MESSAGE_FLAGS,
29
- });
30
- return;
31
- }
32
- const pending = pendingPermissions.get(channel.id);
33
- if (!pending) {
34
- await command.reply({
35
- content: 'No pending permission request in this thread',
36
- ephemeral: true,
37
- flags: SILENT_MESSAGE_FLAGS,
38
- });
39
- return;
40
- }
41
- try {
42
- const getClient = await initializeOpencodeForDirectory(pending.directory);
43
- await getClient().postSessionIdPermissionsPermissionId({
44
- path: {
45
- id: pending.permission.sessionID,
46
- permissionID: pending.permission.id,
47
- },
48
- body: {
49
- response: scope,
50
- },
51
- });
52
- pendingPermissions.delete(channel.id);
53
- const msg = scope === 'always'
54
- ? `✅ Permission **accepted** (auto-approve similar requests)`
55
- : `✅ Permission **accepted**`;
56
- await command.reply({ content: msg, flags: SILENT_MESSAGE_FLAGS });
57
- logger.log(`Permission ${pending.permission.id} accepted with scope: ${scope}`);
58
- }
59
- catch (error) {
60
- logger.error('[ACCEPT] Error:', error);
61
- await command.reply({
62
- content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
63
- ephemeral: true,
64
- flags: SILENT_MESSAGE_FLAGS,
65
- });
66
- }
10
+ // Store pending permission contexts by hash
11
+ export const pendingPermissionContexts = new Map();
12
+ /**
13
+ * Show permission dropdown for a permission request.
14
+ * Returns the message ID and context hash for tracking.
15
+ */
16
+ export async function showPermissionDropdown({ thread, permission, directory, }) {
17
+ const contextHash = crypto.randomBytes(8).toString('hex');
18
+ const context = {
19
+ permission,
20
+ directory,
21
+ thread,
22
+ contextHash,
23
+ };
24
+ pendingPermissionContexts.set(contextHash, context);
25
+ const patternStr = permission.patterns.join(', ');
26
+ // Build dropdown options
27
+ const options = [
28
+ {
29
+ label: 'Accept',
30
+ value: 'once',
31
+ description: 'Allow this request only',
32
+ },
33
+ {
34
+ label: 'Accept Always',
35
+ value: 'always',
36
+ description: 'Auto-approve similar requests',
37
+ },
38
+ {
39
+ label: 'Deny',
40
+ value: 'reject',
41
+ description: 'Reject this permission request',
42
+ },
43
+ ];
44
+ const selectMenu = new StringSelectMenuBuilder()
45
+ .setCustomId(`permission:${contextHash}`)
46
+ .setPlaceholder('Choose an action')
47
+ .addOptions(options);
48
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
49
+ const permissionMessage = await thread.send({
50
+ content: `⚠️ **Permission Required**\n\n` +
51
+ `**Type:** \`${permission.permission}\`\n` +
52
+ (patternStr ? `**Pattern:** \`${patternStr}\`` : ''),
53
+ components: [actionRow],
54
+ flags: NOTIFY_MESSAGE_FLAGS,
55
+ });
56
+ logger.log(`Showed permission dropdown for ${permission.id}`);
57
+ return { messageId: permissionMessage.id, contextHash };
67
58
  }
68
- export async function handleRejectCommand({ command, }) {
69
- const channel = command.channel;
70
- if (!channel) {
71
- await command.reply({
72
- content: 'This command can only be used in a channel',
73
- ephemeral: true,
74
- flags: SILENT_MESSAGE_FLAGS,
75
- });
76
- return;
77
- }
78
- const isThread = [
79
- ChannelType.PublicThread,
80
- ChannelType.PrivateThread,
81
- ChannelType.AnnouncementThread,
82
- ].includes(channel.type);
83
- if (!isThread) {
84
- await command.reply({
85
- content: 'This command can only be used in a thread with an active session',
86
- ephemeral: true,
87
- flags: SILENT_MESSAGE_FLAGS,
88
- });
59
+ /**
60
+ * Handle dropdown selection for permission.
61
+ */
62
+ export async function handlePermissionSelectMenu(interaction) {
63
+ const customId = interaction.customId;
64
+ if (!customId.startsWith('permission:')) {
89
65
  return;
90
66
  }
91
- const pending = pendingPermissions.get(channel.id);
92
- if (!pending) {
93
- await command.reply({
94
- content: 'No pending permission request in this thread',
67
+ const contextHash = customId.replace('permission:', '');
68
+ const context = pendingPermissionContexts.get(contextHash);
69
+ if (!context) {
70
+ await interaction.reply({
71
+ content: 'This permission request has expired or was already handled.',
95
72
  ephemeral: true,
96
- flags: SILENT_MESSAGE_FLAGS,
97
73
  });
98
74
  return;
99
75
  }
76
+ await interaction.deferUpdate();
77
+ const response = interaction.values[0];
100
78
  try {
101
- const getClient = await initializeOpencodeForDirectory(pending.directory);
79
+ const getClient = await initializeOpencodeForDirectory(context.directory);
102
80
  await getClient().postSessionIdPermissionsPermissionId({
103
81
  path: {
104
- id: pending.permission.sessionID,
105
- permissionID: pending.permission.id,
106
- },
107
- body: {
108
- response: 'reject',
82
+ id: context.permission.sessionID,
83
+ permissionID: context.permission.id,
109
84
  },
85
+ body: { response },
110
86
  });
111
- pendingPermissions.delete(channel.id);
112
- await command.reply({
113
- content: `❌ Permission **rejected**`,
114
- flags: SILENT_MESSAGE_FLAGS,
87
+ pendingPermissionContexts.delete(contextHash);
88
+ // Update message: show result and remove dropdown
89
+ const resultText = (() => {
90
+ switch (response) {
91
+ case 'once':
92
+ return '✅ Permission **accepted**';
93
+ case 'always':
94
+ return '✅ Permission **accepted** (auto-approve similar requests)';
95
+ case 'reject':
96
+ return '❌ Permission **rejected**';
97
+ }
98
+ })();
99
+ const patternStr = context.permission.patterns.join(', ');
100
+ await interaction.editReply({
101
+ content: `⚠️ **Permission Required**\n\n` +
102
+ `**Type:** \`${context.permission.permission}\`\n` +
103
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n\n` : '\n') +
104
+ resultText,
105
+ components: [], // Remove the dropdown
115
106
  });
116
- logger.log(`Permission ${pending.permission.id} rejected`);
107
+ logger.log(`Permission ${context.permission.id} ${response}`);
117
108
  }
118
109
  catch (error) {
119
- logger.error('[REJECT] Error:', error);
120
- await command.reply({
121
- content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
122
- ephemeral: true,
123
- flags: SILENT_MESSAGE_FLAGS,
110
+ logger.error('Error handling permission:', error);
111
+ await interaction.editReply({
112
+ content: `Failed to process permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
113
+ components: [],
124
114
  });
125
115
  }
126
116
  }
117
+ /**
118
+ * Clean up a pending permission context (e.g., on auto-reject).
119
+ */
120
+ export function cleanupPermissionContext(contextHash) {
121
+ pendingPermissionContexts.delete(contextHash);
122
+ }
@@ -6,7 +6,7 @@ import { handleSessionCommand, handleSessionAutocomplete } from './commands/sess
6
6
  import { handleResumeCommand, handleResumeAutocomplete } from './commands/resume.js';
7
7
  import { handleAddProjectCommand, handleAddProjectAutocomplete } from './commands/add-project.js';
8
8
  import { handleCreateNewProjectCommand } from './commands/create-new-project.js';
9
- import { handleAcceptCommand, handleRejectCommand } from './commands/permissions.js';
9
+ import { handlePermissionSelectMenu } from './commands/permissions.js';
10
10
  import { handleAbortCommand } from './commands/abort.js';
11
11
  import { handleShareCommand } from './commands/share.js';
12
12
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
@@ -58,13 +58,6 @@ export function registerInteractionHandler({ discordClient, appId, }) {
58
58
  case 'create-new-project':
59
59
  await handleCreateNewProjectCommand({ command: interaction, appId });
60
60
  return;
61
- case 'accept':
62
- case 'accept-always':
63
- await handleAcceptCommand({ command: interaction, appId });
64
- return;
65
- case 'reject':
66
- await handleRejectCommand({ command: interaction, appId });
67
- return;
68
61
  case 'abort':
69
62
  case 'stop':
70
63
  await handleAbortCommand({ command: interaction, appId });
@@ -123,6 +116,10 @@ export function registerInteractionHandler({ discordClient, appId, }) {
123
116
  await handleAskQuestionSelectMenu(interaction);
124
117
  return;
125
118
  }
119
+ if (customId.startsWith('permission:')) {
120
+ await handlePermissionSelectMenu(interaction);
121
+ return;
122
+ }
126
123
  return;
127
124
  }
128
125
  }
@@ -10,6 +10,7 @@ import { getOpencodeSystemMessage } from './system-message.js';
10
10
  import { createLogger } from './logger.js';
11
11
  import { isAbortError } from './utils.js';
12
12
  import { showAskUserQuestionDropdowns } from './commands/ask-question.js';
13
+ import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js';
13
14
  const sessionLogger = createLogger('SESSION');
14
15
  const voiceLogger = createLogger('VOICE');
15
16
  const discordLogger = createLogger('DISCORD');
@@ -142,11 +143,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
142
143
  },
143
144
  body: { response: 'reject' },
144
145
  });
146
+ // Clean up both the pending permission and its dropdown context
147
+ cleanupPermissionContext(pendingPerm.contextHash);
145
148
  pendingPermissions.delete(thread.id);
146
149
  await sendThreadMessage(thread, `⚠️ Previous permission request auto-rejected due to new message`);
147
150
  }
148
151
  catch (e) {
149
152
  sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e);
153
+ cleanupPermissionContext(pendingPerm.contextHash);
150
154
  pendingPermissions.delete(thread.id);
151
155
  }
152
156
  }
@@ -350,15 +354,17 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
350
354
  continue;
351
355
  }
352
356
  sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
353
- const patternStr = permission.patterns.join(', ');
354
- const permissionMessage = await sendThreadMessage(thread, `⚠️ **Permission Required**\n\n` +
355
- `**Type:** \`${permission.permission}\`\n` +
356
- (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
357
- `\nUse \`/accept\` or \`/reject\` to respond.`);
357
+ // Show dropdown instead of text message
358
+ const { messageId, contextHash } = await showPermissionDropdown({
359
+ thread,
360
+ permission,
361
+ directory,
362
+ });
358
363
  pendingPermissions.set(thread.id, {
359
364
  permission,
360
- messageId: permissionMessage.id,
365
+ messageId,
361
366
  directory,
367
+ contextHash,
362
368
  });
363
369
  }
364
370
  else if (event.type === 'permission.replied') {
@@ -369,6 +375,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
369
375
  sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
370
376
  const pending = pendingPermissions.get(thread.id);
371
377
  if (pending && pending.permission.id === requestID) {
378
+ cleanupPermissionContext(pending.contextHash);
372
379
  pendingPermissions.delete(thread.id);
373
380
  }
374
381
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.26",
5
+ "version": "0.4.27",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
package/src/cli.ts CHANGED
@@ -219,18 +219,6 @@ async function registerCommands(token: string, appId: string, userCommands: Open
219
219
  return option
220
220
  })
221
221
  .toJSON(),
222
- new SlashCommandBuilder()
223
- .setName('accept')
224
- .setDescription('Accept a pending permission request (this request only)')
225
- .toJSON(),
226
- new SlashCommandBuilder()
227
- .setName('accept-always')
228
- .setDescription('Accept and auto-approve future requests matching this pattern')
229
- .toJSON(),
230
- new SlashCommandBuilder()
231
- .setName('reject')
232
- .setDescription('Reject a pending permission request')
233
- .toJSON(),
234
222
  new SlashCommandBuilder()
235
223
  .setName('abort')
236
224
  .setDescription('Abort the current OpenCode request in this thread')
@@ -9,7 +9,7 @@ import {
9
9
  type ThreadChannel,
10
10
  } from 'discord.js'
11
11
  import crypto from 'node:crypto'
12
- import { sendThreadMessage } from '../discord-utils.js'
12
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js'
13
13
  import { getOpencodeServerPort } from '../opencode.js'
14
14
  import { createLogger } from '../logger.js'
15
15
 
@@ -111,6 +111,7 @@ export async function showAskUserQuestionDropdowns({
111
111
  await thread.send({
112
112
  content: `**${q.header}**\n${q.question}`,
113
113
  components: [actionRow],
114
+ flags: NOTIFY_MESSAGE_FLAGS,
114
115
  })
115
116
  }
116
117
 
@@ -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
+ }
@@ -7,7 +7,7 @@ import { handleSessionCommand, handleSessionAutocomplete } from './commands/sess
7
7
  import { handleResumeCommand, handleResumeAutocomplete } from './commands/resume.js'
8
8
  import { handleAddProjectCommand, handleAddProjectAutocomplete } from './commands/add-project.js'
9
9
  import { handleCreateNewProjectCommand } from './commands/create-new-project.js'
10
- import { handleAcceptCommand, handleRejectCommand } from './commands/permissions.js'
10
+ import { handlePermissionSelectMenu } from './commands/permissions.js'
11
11
  import { handleAbortCommand } from './commands/abort.js'
12
12
  import { handleShareCommand } from './commands/share.js'
13
13
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js'
@@ -85,15 +85,6 @@ export function registerInteractionHandler({
85
85
  await handleCreateNewProjectCommand({ command: interaction, appId })
86
86
  return
87
87
 
88
- case 'accept':
89
- case 'accept-always':
90
- await handleAcceptCommand({ command: interaction, appId })
91
- return
92
-
93
- case 'reject':
94
- await handleRejectCommand({ command: interaction, appId })
95
- return
96
-
97
88
  case 'abort':
98
89
  case 'stop':
99
90
  await handleAbortCommand({ command: interaction, appId })
@@ -167,6 +158,11 @@ export function registerInteractionHandler({
167
158
  await handleAskQuestionSelectMenu(interaction)
168
159
  return
169
160
  }
161
+
162
+ if (customId.startsWith('permission:')) {
163
+ await handlePermissionSelectMenu(interaction)
164
+ return
165
+ }
170
166
  return
171
167
  }
172
168
  } catch (error) {
@@ -14,6 +14,7 @@ import { getOpencodeSystemMessage } from './system-message.js'
14
14
  import { createLogger } from './logger.js'
15
15
  import { isAbortError } from './utils.js'
16
16
  import { showAskUserQuestionDropdowns } from './commands/ask-question.js'
17
+ import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js'
17
18
 
18
19
  const sessionLogger = createLogger('SESSION')
19
20
  const voiceLogger = createLogger('VOICE')
@@ -23,7 +24,7 @@ export const abortControllers = new Map<string, AbortController>()
23
24
 
24
25
  export const pendingPermissions = new Map<
25
26
  string,
26
- { permission: PermissionRequest; messageId: string; directory: string }
27
+ { permission: PermissionRequest; messageId: string; directory: string; contextHash: string }
27
28
  >()
28
29
 
29
30
  export type QueuedMessage = {
@@ -227,10 +228,13 @@ export async function handleOpencodeSession({
227
228
  },
228
229
  body: { response: 'reject' },
229
230
  })
231
+ // Clean up both the pending permission and its dropdown context
232
+ cleanupPermissionContext(pendingPerm.contextHash)
230
233
  pendingPermissions.delete(thread.id)
231
234
  await sendThreadMessage(thread, `⚠️ Previous permission request auto-rejected due to new message`)
232
235
  } catch (e) {
233
236
  sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e)
237
+ cleanupPermissionContext(pendingPerm.contextHash)
234
238
  pendingPermissions.delete(thread.id)
235
239
  }
236
240
  }
@@ -484,20 +488,18 @@ export async function handleOpencodeSession({
484
488
  `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
485
489
  )
486
490
 
487
- const patternStr = permission.patterns.join(', ')
488
-
489
- const permissionMessage = await sendThreadMessage(
491
+ // Show dropdown instead of text message
492
+ const { messageId, contextHash } = await showPermissionDropdown({
490
493
  thread,
491
- `⚠️ **Permission Required**\n\n` +
492
- `**Type:** \`${permission.permission}\`\n` +
493
- (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
494
- `\nUse \`/accept\` or \`/reject\` to respond.`,
495
- )
494
+ permission,
495
+ directory,
496
+ })
496
497
 
497
498
  pendingPermissions.set(thread.id, {
498
499
  permission,
499
- messageId: permissionMessage.id,
500
+ messageId,
500
501
  directory,
502
+ contextHash,
501
503
  })
502
504
  } else if (event.type === 'permission.replied') {
503
505
  const { requestID, reply, sessionID } = event.properties
@@ -511,6 +513,7 @@ export async function handleOpencodeSession({
511
513
 
512
514
  const pending = pendingPermissions.get(thread.id)
513
515
  if (pending && pending.permission.id === requestID) {
516
+ cleanupPermissionContext(pending.contextHash)
514
517
  pendingPermissions.delete(thread.id)
515
518
  }
516
519
  } else if (event.type === 'question.asked') {