@otto-assistant/otto 0.7.16 → 0.7.18

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.
@@ -5,7 +5,7 @@
5
5
  // Cross-menu state: Discord doesn't expose already-selected values on sibling
6
6
  // select menus in the same message. We track partial selections in the context
7
7
  // Map. Whichever menu fires second sees the first selection stored and applies.
8
- import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
8
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, ButtonInteraction, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
9
9
  import crypto from 'node:crypto';
10
10
  import { setChannelModel, setSessionModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
11
11
  import { initializeOpencodeForDirectory } from '../opencode.js';
@@ -313,21 +313,20 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
313
313
  variant,
314
314
  });
315
315
  logger.log(`Set variant ${variant ?? 'none'} for session ${context.sessionId} (model ${modelId})`);
316
- let retried = false;
317
- if (context.thread) {
318
- const runtime = getRuntime(context.thread.id);
319
- if (runtime) {
320
- retried = await runtime.retryLastUserPrompt();
321
- }
322
- }
323
- const retryNote = retried
324
- ? '\n_Restarting current request with new variant..._'
325
- : '';
316
+ // Show a button so the user can choose to resend the last message.
317
+ // Do not auto-retry — the user may want to add context in a new message instead.
318
+ // Context is kept alive (TTL handles cleanup) so the button can access it.
319
+ const retryButton = new ButtonBuilder()
320
+ .setCustomId(`variant_retry:${contextHash}`)
321
+ .setLabel('Resend last message')
322
+ .setStyle(ButtonStyle.Secondary);
323
+ const actionRow = new ActionRowBuilder().addComponents(retryButton);
326
324
  await interaction.editReply({
327
- content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
325
+ content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
328
326
  flags: MessageFlags.SuppressEmbeds,
329
- components: [],
327
+ components: [actionRow],
330
328
  });
329
+ return;
331
330
  }
332
331
  else if (scope === 'global') {
333
332
  await setGlobalModel({ appId: context.appId, modelId, variant });
@@ -357,7 +356,11 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
357
356
  components: [],
358
357
  });
359
358
  }
360
- pendingVariantContexts.delete(contextHash);
359
+ // Clean up the context from memory, except for session scope where the
360
+ // retry button still needs it. TTL handles cleanup in that case.
361
+ if (scope !== 'session') {
362
+ pendingVariantContexts.delete(contextHash);
363
+ }
361
364
  }
362
365
  catch (error) {
363
366
  logger.error('Error applying variant:', error);
@@ -367,3 +370,41 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
367
370
  });
368
371
  }
369
372
  }
373
+ /**
374
+ * Handle the "Resend last message" button shown after a session-scope variant change.
375
+ * Aborts any active run and resends the last user prompt with the new variant.
376
+ */
377
+ export async function handleVariantRetryButton(interaction) {
378
+ const customId = interaction.customId;
379
+ if (!customId.startsWith('variant_retry:')) {
380
+ return;
381
+ }
382
+ await interaction.deferUpdate();
383
+ const contextHash = customId.replace('variant_retry:', '');
384
+ const context = pendingVariantContexts.get(contextHash);
385
+ // Context may have expired (TTL) — still allow the action via runtime alone.
386
+ const threadId = context?.thread?.id;
387
+ if (!threadId) {
388
+ await interaction.editReply({
389
+ content: 'Could not find the session thread. Please resend your message manually.',
390
+ components: [],
391
+ });
392
+ return;
393
+ }
394
+ const runtime = getRuntime(threadId);
395
+ if (!runtime) {
396
+ await interaction.editReply({
397
+ content: 'No active session found. Please resend your message manually.',
398
+ components: [],
399
+ });
400
+ return;
401
+ }
402
+ const retried = await runtime.retryLastUserPrompt();
403
+ pendingVariantContexts.delete(contextHash);
404
+ await interaction.editReply({
405
+ content: retried
406
+ ? `Restarting with new variant...`
407
+ : `Variant changed. Send a new message to continue.`,
408
+ components: [],
409
+ });
410
+ }
@@ -1,5 +1,5 @@
1
1
  // /model command - Set the preferred model for this channel or session.
2
- import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
2
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, ButtonInteraction, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
3
3
  import crypto from 'node:crypto';
4
4
  import { setChannelModel, setSessionModel, setSessionAgent, getChannelModel, getSessionModel, getSessionAgent, getChannelAgent, getThreadSession, getGlobalModel, setGlobalModel, getVariantCascade, } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
@@ -741,21 +741,20 @@ export async function handleModelScopeSelectMenu(interaction) {
741
741
  }
742
742
  await setSessionModel({ sessionId: context.sessionId, modelId, variant });
743
743
  modelLogger.log(`Set model ${modelId}${variantSuffix} for session ${context.sessionId}`);
744
- let retried = false;
745
- if (context.thread) {
746
- const runtime = getRuntime(context.thread.id);
747
- if (runtime) {
748
- retried = await runtime.retryLastUserPrompt();
749
- }
750
- }
751
- const retryNote = retried
752
- ? '\n_Restarting current request with new model..._'
753
- : '';
744
+ // Show a button so the user can choose to resend the last message.
745
+ // Do not auto-retry — the user may want to add context in a new message instead.
746
+ // Context is kept alive (TTL handles cleanup) so the button can access it.
747
+ const retryButton = new ButtonBuilder()
748
+ .setCustomId(`model_retry:${contextHash}`)
749
+ .setLabel('Resend last message')
750
+ .setStyle(ButtonStyle.Secondary);
751
+ const actionRow = new ActionRowBuilder().addComponents(retryButton);
754
752
  await interaction.editReply({
755
- content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
753
+ content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
756
754
  flags: MessageFlags.SuppressEmbeds,
757
- components: [],
755
+ components: [actionRow],
758
756
  });
757
+ return;
759
758
  }
760
759
  else if (selectedScope === 'global') {
761
760
  if (!context.appId) {
@@ -785,8 +784,11 @@ export async function handleModelScopeSelectMenu(interaction) {
785
784
  components: [],
786
785
  });
787
786
  }
788
- // Clean up the context from memory
789
- deleteModelContext(contextHash);
787
+ // Clean up the context from memory, except for session scope where the
788
+ // retry button still needs it. TTL handles cleanup in that case.
789
+ if (selectedScope !== 'session') {
790
+ deleteModelContext(contextHash);
791
+ }
790
792
  }
791
793
  catch (error) {
792
794
  modelLogger.error('Error saving model preference:', error);
@@ -796,3 +798,41 @@ export async function handleModelScopeSelectMenu(interaction) {
796
798
  });
797
799
  }
798
800
  }
801
+ /**
802
+ * Handle the "Resend last message" button shown after a session-scope model change.
803
+ * Aborts any active run and resends the last user prompt with the new model.
804
+ */
805
+ export async function handleModelRetryButton(interaction) {
806
+ const customId = interaction.customId;
807
+ if (!customId.startsWith('model_retry:')) {
808
+ return;
809
+ }
810
+ await interaction.deferUpdate();
811
+ const contextHash = customId.replace('model_retry:', '');
812
+ const context = pendingModelContexts.get(contextHash);
813
+ // Context may have expired (10 min TTL) — still allow the action via runtime alone.
814
+ const threadId = context?.thread?.id;
815
+ if (!threadId) {
816
+ await interaction.editReply({
817
+ content: 'Could not find the session thread. Please resend your message manually.',
818
+ components: [],
819
+ });
820
+ return;
821
+ }
822
+ const runtime = getRuntime(threadId);
823
+ if (!runtime) {
824
+ await interaction.editReply({
825
+ content: 'No active session found. Please resend your message manually.',
826
+ components: [],
827
+ });
828
+ return;
829
+ }
830
+ const retried = await runtime.retryLastUserPrompt();
831
+ deleteModelContext(contextHash);
832
+ await interaction.editReply({
833
+ content: retried
834
+ ? `Restarting with new model...`
835
+ : `Model changed. Send a new message to continue.`,
836
+ components: [],
837
+ });
838
+ }
@@ -49,6 +49,153 @@ function takePendingPermissionContext(contextHash) {
49
49
  pendingPermissionContexts.delete(contextHash);
50
50
  return ctx;
51
51
  }
52
+ /**
53
+ * Render rich tool context from permission metadata for Discord/Telegram.
54
+ * Shows what the agent is actually trying to do — the command, file path,
55
+ * URL, content preview, etc. — so the user can make an informed decision.
56
+ */
57
+ function renderPermissionContext(permission) {
58
+ const tool = permission.permission.toLowerCase();
59
+ const meta = permission.metadata ?? {};
60
+ // Read a string value from metadata, trying multiple key names
61
+ const getStr = (keys, fallback = '') => {
62
+ for (const key of keys) {
63
+ const val = meta[key];
64
+ if (typeof val === 'string' && val.length > 0)
65
+ return val;
66
+ }
67
+ return fallback;
68
+ };
69
+ const truncate = (s, maxLen = 500) => s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s;
70
+ const clipBlock = (s, limit) => s.length > limit ? s.slice(0, limit - 1) + '…' : s;
71
+ switch (tool) {
72
+ case 'bash':
73
+ case 'shell':
74
+ case 'shell_command':
75
+ case 'cmd':
76
+ case 'terminal': {
77
+ const command = getStr(['command', 'cmd', 'script']);
78
+ const description = getStr(['description']);
79
+ if (!command && !description)
80
+ return '';
81
+ const parts = [];
82
+ if (description)
83
+ parts.push(`> *${clipBlock(description, 200)}*`);
84
+ if (command)
85
+ parts.push(`\`\`\`bash\n${clipBlock(command, 800)}\n\`\`\``);
86
+ return parts.join('\n');
87
+ }
88
+ case 'edit':
89
+ case 'multiedit':
90
+ case 'str_replace':
91
+ case 'str_replace_based_edit_tool':
92
+ case 'apply_patch': {
93
+ const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file']);
94
+ const oldString = getStr(['old_string', 'oldString', 'changes', 'diff']);
95
+ const newString = getStr(['new_string', 'newString']);
96
+ if (!filePath && !oldString && !newString)
97
+ return '';
98
+ const parts = [];
99
+ if (filePath)
100
+ parts.push(`**File:** \`${filePath}\``);
101
+ if (oldString) {
102
+ parts.push(`**Replace:**\n\`\`\`diff\n- ${clipBlock(oldString, 400)}\n+ ${clipBlock(newString || '', 400)}\n\`\`\``);
103
+ }
104
+ else if (newString) {
105
+ parts.push(`**New content:**\n\`\`\`\n${clipBlock(newString, 600)}\n\`\`\``);
106
+ }
107
+ return parts.join('\n');
108
+ }
109
+ case 'write':
110
+ case 'create':
111
+ case 'file_write': {
112
+ const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file']);
113
+ const content = getStr(['content', 'text', 'data']);
114
+ if (!filePath && !content)
115
+ return '';
116
+ const parts = [];
117
+ if (filePath)
118
+ parts.push(`**File:** \`${filePath}\``);
119
+ if (content) {
120
+ parts.push(`\`\`\`\n${clipBlock(content, 600)}\n\`\`\``);
121
+ }
122
+ return parts.join('\n');
123
+ }
124
+ case 'webfetch':
125
+ case 'fetch':
126
+ case 'curl':
127
+ case 'wget': {
128
+ const url = getStr(['url', 'uri', 'endpoint']);
129
+ const method = getStr(['method']) || 'GET';
130
+ if (!url)
131
+ return '';
132
+ return `**URL:** \`${method.toUpperCase()}\` ${url}`;
133
+ }
134
+ case 'read': {
135
+ const filePath = getStr(['filePath', 'file_path', 'path', 'file', 'filename']);
136
+ const parentDir = getStr(['parentDir', 'parent_dir', 'directory']);
137
+ if (!filePath && !parentDir)
138
+ return '';
139
+ const parts = [];
140
+ if (filePath)
141
+ parts.push(`**Reading:** \`${filePath}\``);
142
+ if (parentDir)
143
+ parts.push(`**Directory:** \`${parentDir}\``);
144
+ return parts.join('\n');
145
+ }
146
+ case 'list':
147
+ case 'ls': {
148
+ const listPath = getStr(['path', 'directory', 'filePath']);
149
+ if (!listPath)
150
+ return '';
151
+ return `**Listing:** \`${listPath}\``;
152
+ }
153
+ case 'glob': {
154
+ const pattern = getStr(['pattern', 'glob']);
155
+ if (!pattern)
156
+ return '';
157
+ return `**Pattern:** \`${pattern}\``;
158
+ }
159
+ case 'grep': {
160
+ const pattern = getStr(['pattern', 'query']);
161
+ if (!pattern)
162
+ return '';
163
+ return `**Search:** \`${pattern}\``;
164
+ }
165
+ case 'external_directory': {
166
+ const filepath = getStr(['filepath', 'path', 'directory']);
167
+ const parentDir = getStr(['parentDir', 'parent_dir']);
168
+ const parts = [];
169
+ if (filepath)
170
+ parts.push(`**Path:** \`${filepath}\``);
171
+ if (parentDir)
172
+ parts.push(`**Parent:** \`${parentDir}\``);
173
+ return parts.join('\n');
174
+ }
175
+ case 'task':
176
+ case 'subagent': {
177
+ const description = getStr(['description', 'prompt']);
178
+ if (!description)
179
+ return '';
180
+ return `> ${clipBlock(description, 300)}`;
181
+ }
182
+ default: {
183
+ // Generic: show description or first meaningful metadata field
184
+ const description = getStr(['description', 'action', 'operation', 'command']);
185
+ if (description)
186
+ return `> *${clipBlock(description, 300)}*`;
187
+ const relevantKeys = Object.keys(meta).filter((k) => !['sessionID', 'id', 'type'].includes(k));
188
+ if (relevantKeys.length > 0) {
189
+ const preview = relevantKeys
190
+ .slice(0, 3)
191
+ .map((k) => `${k}: ${String(meta[k]).slice(0, 60)}`)
192
+ .join('\n');
193
+ return `\`\`\`\n${clipBlock(preview, 400)}\n\`\`\``;
194
+ }
195
+ return '';
196
+ }
197
+ }
198
+ }
52
199
  /**
53
200
  * Show permission buttons for a permission request.
54
201
  * Displays 3 buttons in a row: Accept, Accept Always, Deny.
@@ -108,11 +255,13 @@ export async function showPermissionButtons({ thread, permission, directory, per
108
255
  const externalDirLine = permission.permission === 'external_directory'
109
256
  ? `Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n`
110
257
  : '';
258
+ const contextStr = renderPermissionContext(permission);
111
259
  const fullContent = `⚠️ **Permission Required**\n` +
112
260
  subtaskLine +
113
261
  `**Type:** \`${permission.permission}\`\n` +
114
262
  externalDirLine +
115
- (patternStr ? `**Pattern:** \`${patternStr}\`` : '');
263
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
264
+ (contextStr ? `\n${contextStr}` : '');
116
265
  const permissionMessage = await thread.send({
117
266
  content: fullContent.slice(0, 1900),
118
267
  components: [actionRow],
@@ -133,12 +282,14 @@ function updatePermissionMessage({ context, status, }) {
133
282
  const externalDirLine = context.permission.permission === 'external_directory'
134
283
  ? 'Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n'
135
284
  : '';
285
+ const contextStr = renderPermissionContext(context.permission);
136
286
  return message.edit({
137
287
  content: `⚠️ **Permission Required**\n` +
138
288
  `**Type:** \`${context.permission.permission}\`\n` +
139
289
  externalDirLine +
140
290
  (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
141
- status,
291
+ (contextStr ? `\n${contextStr}\n` : '') +
292
+ `\n${status}`,
142
293
  components: [],
143
294
  });
144
295
  })
@@ -1,5 +1,5 @@
1
1
  // /unset-model-override command - Remove model overrides and use default instead.
2
- import { ChatInputCommandInteraction, ChannelType, MessageFlags, } from 'discord.js';
2
+ import { ChatInputCommandInteraction, ButtonInteraction, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
3
3
  import { getChannelModel, getSessionModel, getThreadSession, clearSessionModel, } from '../database.js';
4
4
  import { getPrisma } from '../db.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
@@ -121,19 +121,58 @@ export async function handleUnsetModelCommand({ interaction, appId, }) {
121
121
  ? 'none'
122
122
  : `\`${newModelInfo.model}\` (${formatModelSource(newModelInfo.type, 'agentName' in newModelInfo ? newModelInfo.agentName : undefined)})`;
123
123
  }
124
- // Check if there's a running request and abort+retry with new model (only for session changes in threads)
125
- let retried = false;
124
+ const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel';
125
+ // Show a button so the user can choose to resend the last message (session scope only).
126
+ // Do not auto-retry — the user may want to add context in a new message instead.
126
127
  if (isThread && clearedType === 'session' && sessionId) {
127
128
  const runtime = getRuntime(channel.id);
128
129
  if (runtime) {
129
- retried = await runtime.retryLastUserPrompt();
130
+ const retryButton = new ButtonBuilder()
131
+ .setCustomId(`unset_model_retry:${channel.id}`)
132
+ .setLabel('Resend last message')
133
+ .setStyle(ButtonStyle.Secondary);
134
+ const actionRow = new ActionRowBuilder().addComponents(retryButton);
135
+ await interaction.editReply({
136
+ content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
137
+ components: [actionRow],
138
+ });
139
+ return;
130
140
  }
131
141
  }
132
- const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel';
133
- const retriedText = retried
134
- ? '\n_Restarting current request with new model..._'
135
- : '';
136
142
  await interaction.editReply({
137
- content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}${retriedText}`,
143
+ content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
144
+ });
145
+ }
146
+ /**
147
+ * Handle the "Resend last message" button after clearing a session model override.
148
+ */
149
+ export async function handleUnsetModelRetryButton(interaction) {
150
+ const customId = interaction.customId;
151
+ if (!customId.startsWith('unset_model_retry:')) {
152
+ return;
153
+ }
154
+ await interaction.deferUpdate();
155
+ const threadId = customId.replace('unset_model_retry:', '');
156
+ if (!threadId) {
157
+ await interaction.editReply({
158
+ content: 'Could not find the session. Please resend your message manually.',
159
+ components: [],
160
+ });
161
+ return;
162
+ }
163
+ const runtime = getRuntime(threadId);
164
+ if (!runtime) {
165
+ await interaction.editReply({
166
+ content: 'No active session found. Please resend your message manually.',
167
+ components: [],
168
+ });
169
+ return;
170
+ }
171
+ const retried = await runtime.retryLastUserPrompt();
172
+ await interaction.editReply({
173
+ content: retried
174
+ ? 'Restarting with new model...'
175
+ : 'Model changed. Send a new message to continue.',
176
+ components: [],
138
177
  });
139
178
  }
@@ -21,8 +21,8 @@ import { handleDiffCommand } from './commands/diff.js';
21
21
  import { handleForkCommand, handleForkSelectMenu, } from './commands/fork.js';
22
22
  import { handleForkSubagentCommand, handleForkSubagentSelectMenu, } from './commands/fork-subagent.js';
23
23
  import { handleBtwCommand } from './commands/btw.js';
24
- import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, } from './commands/model.js';
25
- import { handleUnsetModelCommand } from './commands/unset-model.js';
24
+ import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, handleModelRetryButton, } from './commands/model.js';
25
+ import { handleUnsetModelCommand, handleUnsetModelRetryButton, } from './commands/unset-model.js';
26
26
  import { handleLoginCommand, handleLoginSelect, handleLoginTextButton, handleLoginTextModalSubmit, handleLoginApiKeyButton, handleOAuthCodeButton, handleOAuthCodeModalSubmit, handleApiKeyModalSubmit, } from './commands/login.js';
27
27
  import { handleTranscriptionApiKeyButton, handleTranscriptionApiKeyCommand, handleTranscriptionApiKeyModalSubmit, } from './commands/gemini-apikey.js';
28
28
  import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand, } from './commands/agent.js';
@@ -44,7 +44,7 @@ import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
44
44
  import { handleScreenshareCommand, handleScreenshareStopCommand, } from './commands/screenshare.js';
45
45
  import { handleVscodeCommand } from './commands/vscode.js';
46
46
  import { handleModelVariantSelectMenu } from './commands/model.js';
47
- import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, } from './commands/model-variant.js';
47
+ import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, handleVariantRetryButton, } from './commands/model-variant.js';
48
48
  import { hasOttoBotPermission } from './discord-utils.js';
49
49
  import { createLogger, LogPrefix } from './logger.js';
50
50
  import { notifyError } from './sentry.js';
@@ -302,6 +302,18 @@ export function registerInteractionHandler({ discordClient, appId, }) {
302
302
  await handleHtmlActionButton(interaction);
303
303
  return;
304
304
  }
305
+ if (customId.startsWith('model_retry:')) {
306
+ await handleModelRetryButton(interaction);
307
+ return;
308
+ }
309
+ if (customId.startsWith('variant_retry:')) {
310
+ await handleVariantRetryButton(interaction);
311
+ return;
312
+ }
313
+ if (customId.startsWith('unset_model_retry:')) {
314
+ await handleUnsetModelRetryButton(interaction);
315
+ return;
316
+ }
305
317
  return;
306
318
  }
307
319
  if (interaction.isStringSelectMenu()) {
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "module": "index.ts",
9
9
  "type": "module",
10
- "version": "0.7.16",
10
+ "version": "0.7.18",
11
11
  "scripts": {
12
12
  "dev": "tsx src/bin.ts",
13
13
  "prepublishOnly": "pnpm build",
@@ -9,7 +9,10 @@
9
9
  import {
10
10
  ChatInputCommandInteraction,
11
11
  StringSelectMenuInteraction,
12
+ ButtonInteraction,
12
13
  StringSelectMenuBuilder,
14
+ ButtonBuilder,
15
+ ButtonStyle,
13
16
  ActionRowBuilder,
14
17
  ChannelType,
15
18
  type ThreadChannel,
@@ -427,22 +430,22 @@ async function applyVariant({
427
430
  `Set variant ${variant ?? 'none'} for session ${context.sessionId} (model ${modelId})`,
428
431
  )
429
432
 
430
- let retried = false
431
- if (context.thread) {
432
- const runtime = getRuntime(context.thread.id)
433
- if (runtime) {
434
- retried = await runtime.retryLastUserPrompt()
435
- }
436
- }
433
+ // Show a button so the user can choose to resend the last message.
434
+ // Do not auto-retry — the user may want to add context in a new message instead.
435
+ // Context is kept alive (TTL handles cleanup) so the button can access it.
436
+ const retryButton = new ButtonBuilder()
437
+ .setCustomId(`variant_retry:${contextHash}`)
438
+ .setLabel('Resend last message')
439
+ .setStyle(ButtonStyle.Secondary)
440
+
441
+ const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
437
442
 
438
- const retryNote = retried
439
- ? '\n_Restarting current request with new variant..._'
440
- : ''
441
443
  await interaction.editReply({
442
- content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
444
+ content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
443
445
  flags: MessageFlags.SuppressEmbeds,
444
- components: [],
446
+ components: [actionRow],
445
447
  })
448
+ return
446
449
  } else if (scope === 'global') {
447
450
  await setGlobalModel({ appId: context.appId, modelId, variant })
448
451
  await setChannelModel({
@@ -477,7 +480,11 @@ async function applyVariant({
477
480
  })
478
481
  }
479
482
 
480
- pendingVariantContexts.delete(contextHash)
483
+ // Clean up the context from memory, except for session scope where the
484
+ // retry button still needs it. TTL handles cleanup in that case.
485
+ if (scope !== 'session') {
486
+ pendingVariantContexts.delete(contextHash)
487
+ }
481
488
  } catch (error) {
482
489
  logger.error('Error applying variant:', error)
483
490
  await interaction.editReply({
@@ -486,3 +493,51 @@ async function applyVariant({
486
493
  })
487
494
  }
488
495
  }
496
+
497
+ /**
498
+ * Handle the "Resend last message" button shown after a session-scope variant change.
499
+ * Aborts any active run and resends the last user prompt with the new variant.
500
+ */
501
+ export async function handleVariantRetryButton(
502
+ interaction: ButtonInteraction,
503
+ ): Promise<void> {
504
+ const customId = interaction.customId
505
+ if (!customId.startsWith('variant_retry:')) {
506
+ return
507
+ }
508
+
509
+ await interaction.deferUpdate()
510
+
511
+ const contextHash = customId.replace('variant_retry:', '')
512
+ const context = pendingVariantContexts.get(contextHash)
513
+
514
+ // Context may have expired (TTL) — still allow the action via runtime alone.
515
+ const threadId = context?.thread?.id
516
+
517
+ if (!threadId) {
518
+ await interaction.editReply({
519
+ content: 'Could not find the session thread. Please resend your message manually.',
520
+ components: [],
521
+ })
522
+ return
523
+ }
524
+
525
+ const runtime = getRuntime(threadId)
526
+ if (!runtime) {
527
+ await interaction.editReply({
528
+ content: 'No active session found. Please resend your message manually.',
529
+ components: [],
530
+ })
531
+ return
532
+ }
533
+
534
+ const retried = await runtime.retryLastUserPrompt()
535
+ pendingVariantContexts.delete(contextHash)
536
+
537
+ await interaction.editReply({
538
+ content: retried
539
+ ? `Restarting with new variant...`
540
+ : `Variant changed. Send a new message to continue.`,
541
+ components: [],
542
+ })
543
+ }
@@ -3,7 +3,10 @@
3
3
  import {
4
4
  ChatInputCommandInteraction,
5
5
  StringSelectMenuInteraction,
6
+ ButtonInteraction,
6
7
  StringSelectMenuBuilder,
8
+ ButtonBuilder,
9
+ ButtonStyle,
7
10
  ActionRowBuilder,
8
11
  ChannelType,
9
12
  type ThreadChannel,
@@ -1020,22 +1023,22 @@ export async function handleModelScopeSelectMenu(
1020
1023
  `Set model ${modelId}${variantSuffix} for session ${context.sessionId}`,
1021
1024
  )
1022
1025
 
1023
- let retried = false
1024
- if (context.thread) {
1025
- const runtime = getRuntime(context.thread.id)
1026
- if (runtime) {
1027
- retried = await runtime.retryLastUserPrompt()
1028
- }
1029
- }
1026
+ // Show a button so the user can choose to resend the last message.
1027
+ // Do not auto-retry — the user may want to add context in a new message instead.
1028
+ // Context is kept alive (TTL handles cleanup) so the button can access it.
1029
+ const retryButton = new ButtonBuilder()
1030
+ .setCustomId(`model_retry:${contextHash}`)
1031
+ .setLabel('Resend last message')
1032
+ .setStyle(ButtonStyle.Secondary)
1033
+
1034
+ const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
1030
1035
 
1031
- const retryNote = retried
1032
- ? '\n_Restarting current request with new model..._'
1033
- : ''
1034
1036
  await interaction.editReply({
1035
- content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
1037
+ content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
1036
1038
  flags: MessageFlags.SuppressEmbeds,
1037
- components: [],
1039
+ components: [actionRow],
1038
1040
  })
1041
+ return
1039
1042
  } else if (selectedScope === 'global') {
1040
1043
  if (!context.appId) {
1041
1044
  deleteModelContext(contextHash)
@@ -1070,8 +1073,11 @@ export async function handleModelScopeSelectMenu(
1070
1073
  })
1071
1074
  }
1072
1075
 
1073
- // Clean up the context from memory
1074
- deleteModelContext(contextHash)
1076
+ // Clean up the context from memory, except for session scope where the
1077
+ // retry button still needs it. TTL handles cleanup in that case.
1078
+ if (selectedScope !== 'session') {
1079
+ deleteModelContext(contextHash)
1080
+ }
1075
1081
  } catch (error) {
1076
1082
  modelLogger.error('Error saving model preference:', error)
1077
1083
  await interaction.editReply({
@@ -1080,3 +1086,51 @@ export async function handleModelScopeSelectMenu(
1080
1086
  })
1081
1087
  }
1082
1088
  }
1089
+
1090
+ /**
1091
+ * Handle the "Resend last message" button shown after a session-scope model change.
1092
+ * Aborts any active run and resends the last user prompt with the new model.
1093
+ */
1094
+ export async function handleModelRetryButton(
1095
+ interaction: ButtonInteraction,
1096
+ ): Promise<void> {
1097
+ const customId = interaction.customId
1098
+ if (!customId.startsWith('model_retry:')) {
1099
+ return
1100
+ }
1101
+
1102
+ await interaction.deferUpdate()
1103
+
1104
+ const contextHash = customId.replace('model_retry:', '')
1105
+ const context = pendingModelContexts.get(contextHash)
1106
+
1107
+ // Context may have expired (10 min TTL) — still allow the action via runtime alone.
1108
+ const threadId = context?.thread?.id
1109
+
1110
+ if (!threadId) {
1111
+ await interaction.editReply({
1112
+ content: 'Could not find the session thread. Please resend your message manually.',
1113
+ components: [],
1114
+ })
1115
+ return
1116
+ }
1117
+
1118
+ const runtime = getRuntime(threadId)
1119
+ if (!runtime) {
1120
+ await interaction.editReply({
1121
+ content: 'No active session found. Please resend your message manually.',
1122
+ components: [],
1123
+ })
1124
+ return
1125
+ }
1126
+
1127
+ const retried = await runtime.retryLastUserPrompt()
1128
+ deleteModelContext(contextHash)
1129
+
1130
+ await interaction.editReply({
1131
+ content: retried
1132
+ ? `Restarting with new model...`
1133
+ : `Model changed. Send a new message to continue.`,
1134
+ components: [],
1135
+ })
1136
+ }
@@ -92,6 +92,152 @@ function takePendingPermissionContext(contextHash: string): PendingPermissionCon
92
92
  return ctx
93
93
  }
94
94
 
95
+ /**
96
+ * Render rich tool context from permission metadata for Discord/Telegram.
97
+ * Shows what the agent is actually trying to do — the command, file path,
98
+ * URL, content preview, etc. — so the user can make an informed decision.
99
+ */
100
+ function renderPermissionContext(permission: PermissionRequest): string {
101
+ const tool = permission.permission.toLowerCase()
102
+ const meta = permission.metadata ?? {}
103
+
104
+ // Read a string value from metadata, trying multiple key names
105
+ const getStr = (keys: string[], fallback = ''): string => {
106
+ for (const key of keys) {
107
+ const val = meta[key]
108
+ if (typeof val === 'string' && val.length > 0) return val
109
+ }
110
+ return fallback
111
+ }
112
+
113
+ const truncate = (s: string, maxLen = 500): string =>
114
+ s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s
115
+
116
+ const clipBlock = (s: string, limit: number): string =>
117
+ s.length > limit ? s.slice(0, limit - 1) + '…' : s
118
+
119
+ switch (tool) {
120
+ case 'bash':
121
+ case 'shell':
122
+ case 'shell_command':
123
+ case 'cmd':
124
+ case 'terminal': {
125
+ const command = getStr(['command', 'cmd', 'script'])
126
+ const description = getStr(['description'])
127
+ if (!command && !description) return ''
128
+ const parts: string[] = []
129
+ if (description) parts.push(`> *${clipBlock(description, 200)}*`)
130
+ if (command) parts.push(`\`\`\`bash\n${clipBlock(command, 800)}\n\`\`\``)
131
+ return parts.join('\n')
132
+ }
133
+
134
+ case 'edit':
135
+ case 'multiedit':
136
+ case 'str_replace':
137
+ case 'str_replace_based_edit_tool':
138
+ case 'apply_patch': {
139
+ const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file'])
140
+ const oldString = getStr(['old_string', 'oldString', 'changes', 'diff'])
141
+ const newString = getStr(['new_string', 'newString'])
142
+ if (!filePath && !oldString && !newString) return ''
143
+ const parts: string[] = []
144
+ if (filePath) parts.push(`**File:** \`${filePath}\``)
145
+ if (oldString) {
146
+ parts.push(`**Replace:**\n\`\`\`diff\n- ${clipBlock(oldString, 400)}\n+ ${clipBlock(newString || '', 400)}\n\`\`\``)
147
+ } else if (newString) {
148
+ parts.push(`**New content:**\n\`\`\`\n${clipBlock(newString, 600)}\n\`\`\``)
149
+ }
150
+ return parts.join('\n')
151
+ }
152
+
153
+ case 'write':
154
+ case 'create':
155
+ case 'file_write': {
156
+ const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file'])
157
+ const content = getStr(['content', 'text', 'data'])
158
+ if (!filePath && !content) return ''
159
+ const parts: string[] = []
160
+ if (filePath) parts.push(`**File:** \`${filePath}\``)
161
+ if (content) {
162
+ parts.push(`\`\`\`\n${clipBlock(content, 600)}\n\`\`\``)
163
+ }
164
+ return parts.join('\n')
165
+ }
166
+
167
+ case 'webfetch':
168
+ case 'fetch':
169
+ case 'curl':
170
+ case 'wget': {
171
+ const url = getStr(['url', 'uri', 'endpoint'])
172
+ const method = getStr(['method']) || 'GET'
173
+ if (!url) return ''
174
+ return `**URL:** \`${method.toUpperCase()}\` ${url}`
175
+ }
176
+
177
+ case 'read': {
178
+ const filePath = getStr(['filePath', 'file_path', 'path', 'file', 'filename'])
179
+ const parentDir = getStr(['parentDir', 'parent_dir', 'directory'])
180
+ if (!filePath && !parentDir) return ''
181
+ const parts: string[] = []
182
+ if (filePath) parts.push(`**Reading:** \`${filePath}\``)
183
+ if (parentDir) parts.push(`**Directory:** \`${parentDir}\``)
184
+ return parts.join('\n')
185
+ }
186
+
187
+ case 'list':
188
+ case 'ls': {
189
+ const listPath = getStr(['path', 'directory', 'filePath'])
190
+ if (!listPath) return ''
191
+ return `**Listing:** \`${listPath}\``
192
+ }
193
+
194
+ case 'glob': {
195
+ const pattern = getStr(['pattern', 'glob'])
196
+ if (!pattern) return ''
197
+ return `**Pattern:** \`${pattern}\``
198
+ }
199
+
200
+ case 'grep': {
201
+ const pattern = getStr(['pattern', 'query'])
202
+ if (!pattern) return ''
203
+ return `**Search:** \`${pattern}\``
204
+ }
205
+
206
+ case 'external_directory': {
207
+ const filepath = getStr(['filepath', 'path', 'directory'])
208
+ const parentDir = getStr(['parentDir', 'parent_dir'])
209
+ const parts: string[] = []
210
+ if (filepath) parts.push(`**Path:** \`${filepath}\``)
211
+ if (parentDir) parts.push(`**Parent:** \`${parentDir}\``)
212
+ return parts.join('\n')
213
+ }
214
+
215
+ case 'task':
216
+ case 'subagent': {
217
+ const description = getStr(['description', 'prompt'])
218
+ if (!description) return ''
219
+ return `> ${clipBlock(description, 300)}`
220
+ }
221
+
222
+ default: {
223
+ // Generic: show description or first meaningful metadata field
224
+ const description = getStr(['description', 'action', 'operation', 'command'])
225
+ if (description) return `> *${clipBlock(description, 300)}*`
226
+ const relevantKeys = Object.keys(meta).filter(
227
+ (k) => !['sessionID', 'id', 'type'].includes(k),
228
+ )
229
+ if (relevantKeys.length > 0) {
230
+ const preview = relevantKeys
231
+ .slice(0, 3)
232
+ .map((k) => `${k}: ${String(meta[k]).slice(0, 60)}`)
233
+ .join('\n')
234
+ return `\`\`\`\n${clipBlock(preview, 400)}\n\`\`\``
235
+ }
236
+ return ''
237
+ }
238
+ }
239
+ }
240
+
95
241
  /**
96
242
  * Show permission buttons for a permission request.
97
243
  * Displays 3 buttons in a row: Accept, Accept Always, Deny.
@@ -178,12 +324,14 @@ export async function showPermissionButtons({
178
324
  permission.permission === 'external_directory'
179
325
  ? `Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n`
180
326
  : ''
327
+ const contextStr = renderPermissionContext(permission)
181
328
  const fullContent =
182
329
  `⚠️ **Permission Required**\n` +
183
330
  subtaskLine +
184
331
  `**Type:** \`${permission.permission}\`\n` +
185
332
  externalDirLine +
186
- (patternStr ? `**Pattern:** \`${patternStr}\`` : '')
333
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
334
+ (contextStr ? `\n${contextStr}` : '')
187
335
  const permissionMessage = await thread.send({
188
336
  content: fullContent.slice(0, 1900),
189
337
  components: [actionRow],
@@ -215,13 +363,15 @@ function updatePermissionMessage({
215
363
  context.permission.permission === 'external_directory'
216
364
  ? 'Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n'
217
365
  : ''
366
+ const contextStr = renderPermissionContext(context.permission)
218
367
  return message.edit({
219
368
  content:
220
369
  `⚠️ **Permission Required**\n` +
221
370
  `**Type:** \`${context.permission.permission}\`\n` +
222
371
  externalDirLine +
223
372
  (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
224
- status,
373
+ (contextStr ? `\n${contextStr}\n` : '') +
374
+ `\n${status}`,
225
375
  components: [],
226
376
  })
227
377
  })
@@ -2,6 +2,10 @@
2
2
 
3
3
  import {
4
4
  ChatInputCommandInteraction,
5
+ ButtonInteraction,
6
+ ButtonBuilder,
7
+ ButtonStyle,
8
+ ActionRowBuilder,
5
9
  ChannelType,
6
10
  type ThreadChannel,
7
11
  type TextChannel,
@@ -154,21 +158,69 @@ export async function handleUnsetModelCommand({
154
158
  : `\`${newModelInfo.model}\` (${formatModelSource(newModelInfo.type, 'agentName' in newModelInfo ? newModelInfo.agentName : undefined)})`
155
159
  }
156
160
 
157
- // Check if there's a running request and abort+retry with new model (only for session changes in threads)
158
- let retried = false
161
+ const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel'
162
+ // Show a button so the user can choose to resend the last message (session scope only).
163
+ // Do not auto-retry — the user may want to add context in a new message instead.
159
164
  if (isThread && clearedType === 'session' && sessionId) {
160
165
  const runtime = getRuntime(channel.id)
161
166
  if (runtime) {
162
- retried = await runtime.retryLastUserPrompt()
167
+ const retryButton = new ButtonBuilder()
168
+ .setCustomId(`unset_model_retry:${channel.id}`)
169
+ .setLabel('Resend last message')
170
+ .setStyle(ButtonStyle.Secondary)
171
+
172
+ const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
173
+
174
+ await interaction.editReply({
175
+ content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
176
+ components: [actionRow],
177
+ })
178
+ return
163
179
  }
164
180
  }
165
181
 
166
- const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel'
167
- const retriedText = retried
168
- ? '\n_Restarting current request with new model..._'
169
- : ''
182
+ await interaction.editReply({
183
+ content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
184
+ })
185
+ }
186
+
187
+ /**
188
+ * Handle the "Resend last message" button after clearing a session model override.
189
+ */
190
+ export async function handleUnsetModelRetryButton(
191
+ interaction: ButtonInteraction,
192
+ ): Promise<void> {
193
+ const customId = interaction.customId
194
+ if (!customId.startsWith('unset_model_retry:')) {
195
+ return
196
+ }
197
+
198
+ await interaction.deferUpdate()
199
+
200
+ const threadId = customId.replace('unset_model_retry:', '')
201
+ if (!threadId) {
202
+ await interaction.editReply({
203
+ content: 'Could not find the session. Please resend your message manually.',
204
+ components: [],
205
+ })
206
+ return
207
+ }
208
+
209
+ const runtime = getRuntime(threadId)
210
+ if (!runtime) {
211
+ await interaction.editReply({
212
+ content: 'No active session found. Please resend your message manually.',
213
+ components: [],
214
+ })
215
+ return
216
+ }
217
+
218
+ const retried = await runtime.retryLastUserPrompt()
170
219
 
171
220
  await interaction.editReply({
172
- content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}${retriedText}`,
221
+ content: retried
222
+ ? 'Restarting with new model...'
223
+ : 'Model changed. Send a new message to continue.',
224
+ components: [],
173
225
  })
174
226
  }
@@ -57,8 +57,12 @@ import {
57
57
  handleProviderSelectMenu,
58
58
  handleModelSelectMenu,
59
59
  handleModelScopeSelectMenu,
60
+ handleModelRetryButton,
60
61
  } from './commands/model.js'
61
- import { handleUnsetModelCommand } from './commands/unset-model.js'
62
+ import {
63
+ handleUnsetModelCommand,
64
+ handleUnsetModelRetryButton,
65
+ } from './commands/unset-model.js'
62
66
  import {
63
67
  handleLoginCommand,
64
68
  handleLoginSelect,
@@ -116,6 +120,7 @@ import {
116
120
  handleModelVariantCommand,
117
121
  handleVariantQuickSelectMenu,
118
122
  handleVariantScopeSelectMenu,
123
+ handleVariantRetryButton,
119
124
  } from './commands/model-variant.js'
120
125
  import { hasOttoBotPermission } from './discord-utils.js'
121
126
  import { createLogger, LogPrefix } from './logger.js'
@@ -463,6 +468,21 @@ export function registerInteractionHandler({
463
468
  return
464
469
  }
465
470
 
471
+ if (customId.startsWith('model_retry:')) {
472
+ await handleModelRetryButton(interaction)
473
+ return
474
+ }
475
+
476
+ if (customId.startsWith('variant_retry:')) {
477
+ await handleVariantRetryButton(interaction)
478
+ return
479
+ }
480
+
481
+ if (customId.startsWith('unset_model_retry:')) {
482
+ await handleUnsetModelRetryButton(interaction)
483
+ return
484
+ }
485
+
466
486
  return
467
487
  }
468
488