disunday 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/ai-tool-to-genai.js +208 -0
  2. package/dist/ai-tool-to-genai.test.js +267 -0
  3. package/dist/channel-management.js +96 -0
  4. package/dist/cli.js +1674 -0
  5. package/dist/commands/abort.js +89 -0
  6. package/dist/commands/add-project.js +117 -0
  7. package/dist/commands/agent.js +250 -0
  8. package/dist/commands/ask-question.js +219 -0
  9. package/dist/commands/compact.js +126 -0
  10. package/dist/commands/context-menu.js +171 -0
  11. package/dist/commands/context.js +89 -0
  12. package/dist/commands/cost.js +93 -0
  13. package/dist/commands/create-new-project.js +111 -0
  14. package/dist/commands/diff.js +77 -0
  15. package/dist/commands/export.js +100 -0
  16. package/dist/commands/files.js +73 -0
  17. package/dist/commands/fork.js +199 -0
  18. package/dist/commands/help.js +54 -0
  19. package/dist/commands/login.js +488 -0
  20. package/dist/commands/merge-worktree.js +165 -0
  21. package/dist/commands/model.js +325 -0
  22. package/dist/commands/permissions.js +140 -0
  23. package/dist/commands/ping.js +13 -0
  24. package/dist/commands/queue.js +133 -0
  25. package/dist/commands/remove-project.js +119 -0
  26. package/dist/commands/rename.js +70 -0
  27. package/dist/commands/restart-opencode-server.js +77 -0
  28. package/dist/commands/resume.js +276 -0
  29. package/dist/commands/run-config.js +79 -0
  30. package/dist/commands/run.js +240 -0
  31. package/dist/commands/schedule.js +170 -0
  32. package/dist/commands/session-info.js +58 -0
  33. package/dist/commands/session.js +191 -0
  34. package/dist/commands/settings.js +84 -0
  35. package/dist/commands/share.js +89 -0
  36. package/dist/commands/status.js +79 -0
  37. package/dist/commands/sync.js +119 -0
  38. package/dist/commands/theme.js +53 -0
  39. package/dist/commands/types.js +2 -0
  40. package/dist/commands/undo-redo.js +170 -0
  41. package/dist/commands/user-command.js +135 -0
  42. package/dist/commands/verbosity.js +59 -0
  43. package/dist/commands/worktree-settings.js +50 -0
  44. package/dist/commands/worktree.js +288 -0
  45. package/dist/config.js +139 -0
  46. package/dist/database.js +585 -0
  47. package/dist/discord-bot.js +700 -0
  48. package/dist/discord-utils.js +336 -0
  49. package/dist/discord-utils.test.js +20 -0
  50. package/dist/errors.js +193 -0
  51. package/dist/escape-backticks.test.js +429 -0
  52. package/dist/format-tables.js +96 -0
  53. package/dist/format-tables.test.js +418 -0
  54. package/dist/genai-worker-wrapper.js +109 -0
  55. package/dist/genai-worker.js +299 -0
  56. package/dist/genai.js +230 -0
  57. package/dist/image-utils.js +107 -0
  58. package/dist/interaction-handler.js +289 -0
  59. package/dist/limit-heading-depth.js +25 -0
  60. package/dist/limit-heading-depth.test.js +105 -0
  61. package/dist/logger.js +111 -0
  62. package/dist/markdown.js +323 -0
  63. package/dist/markdown.test.js +269 -0
  64. package/dist/message-formatting.js +447 -0
  65. package/dist/message-formatting.test.js +73 -0
  66. package/dist/openai-realtime.js +226 -0
  67. package/dist/opencode.js +224 -0
  68. package/dist/reaction-handler.js +128 -0
  69. package/dist/scheduler.js +93 -0
  70. package/dist/security.js +200 -0
  71. package/dist/session-handler.js +1436 -0
  72. package/dist/system-message.js +138 -0
  73. package/dist/tools.js +354 -0
  74. package/dist/unnest-code-blocks.js +117 -0
  75. package/dist/unnest-code-blocks.test.js +432 -0
  76. package/dist/utils.js +95 -0
  77. package/dist/voice-handler.js +569 -0
  78. package/dist/voice.js +344 -0
  79. package/dist/worker-types.js +4 -0
  80. package/dist/worktree-utils.js +134 -0
  81. package/dist/xml.js +90 -0
  82. package/dist/xml.test.js +32 -0
  83. package/package.json +84 -0
@@ -0,0 +1,240 @@
1
+ // /run command - Execute terminal commands directly from Discord.
2
+ // Supports immediate execution and background mode with dual notifications.
3
+ import { ChannelType, EmbedBuilder, } from 'discord.js';
4
+ import { exec } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { getChannelDirectory, getRunConfig } from '../database.js';
7
+ import { resolveTextChannel, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
8
+ import { getBashWhitelist } from '../config.js';
9
+ import { createLogger, LogPrefix } from '../logger.js';
10
+ const execAsync = promisify(exec);
11
+ const logger = createLogger(LogPrefix.DISCORD);
12
+ const MAX_TIMEOUT_MS = 300_000;
13
+ const MAX_OUTPUT_LENGTH = 1900;
14
+ const backgroundJobs = new Map();
15
+ function validateCommand(command) {
16
+ const trimmed = command.trim();
17
+ if (!trimmed) {
18
+ return { valid: false, reason: 'Empty command' };
19
+ }
20
+ // Extract the base command (first word, handle paths)
21
+ const firstWord = trimmed.split(/\s+/)[0] || '';
22
+ const baseCommand = firstWord.split('/').pop() || firstWord;
23
+ const whitelist = getBashWhitelist();
24
+ // Check if wildcard is allowed
25
+ if (whitelist.includes('*')) {
26
+ return { valid: true };
27
+ }
28
+ if (!whitelist.includes(baseCommand)) {
29
+ return {
30
+ valid: false,
31
+ reason: `Command \`${baseCommand}\` is not in the whitelist.\nAllowed: ${whitelist.slice(0, 10).join(', ')}${whitelist.length > 10 ? '...' : ''}`,
32
+ };
33
+ }
34
+ return { valid: true };
35
+ }
36
+ async function sendSystemNotification({ title, message, }) {
37
+ if (process.platform === 'darwin') {
38
+ const escapedTitle = title.replace(/"/g, '\\"');
39
+ const escapedMessage = message.replace(/"/g, '\\"');
40
+ try {
41
+ await execAsync(`osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`);
42
+ }
43
+ catch {
44
+ // Ignore notification failures
45
+ }
46
+ }
47
+ // Terminal bell
48
+ process.stdout.write('\x07');
49
+ }
50
+ async function sendWebhook({ url, payload, }) {
51
+ try {
52
+ await fetch(url, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify(payload),
56
+ });
57
+ }
58
+ catch {
59
+ // Ignore webhook failures
60
+ }
61
+ }
62
+ function createResultEmbed({ command, stdout, stderr, exitCode, durationMs, background, }) {
63
+ const success = exitCode === 0;
64
+ const durationSec = (durationMs / 1000).toFixed(1);
65
+ const embed = new EmbedBuilder()
66
+ .setColor(success ? 0x00ff00 : 0xff0000)
67
+ .setTitle(`${success ? 'āœ…' : 'āŒ'} ${background ? '[BG] ' : ''}${command.slice(0, 50)}${command.length > 50 ? '...' : ''}`)
68
+ .setFooter({ text: `Exit: ${exitCode ?? 'killed'} | ${durationSec}s` })
69
+ .setTimestamp();
70
+ const output = stdout || stderr || '(no output)';
71
+ const truncated = output.length > MAX_OUTPUT_LENGTH
72
+ ? output.slice(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)'
73
+ : output;
74
+ embed.setDescription(`\`\`\`\n${truncated}\n\`\`\``);
75
+ return embed;
76
+ }
77
+ export async function handleRunCommand({ command, }) {
78
+ const channel = command.channel;
79
+ if (!channel) {
80
+ await command.reply({
81
+ content: 'This command can only be used in a channel',
82
+ ephemeral: true,
83
+ });
84
+ return;
85
+ }
86
+ // Get options
87
+ const cmdString = command.options.getString('command', true);
88
+ const background = command.options.getBoolean('background') ?? false;
89
+ const timeoutSec = command.options.getInteger('timeout') ?? 30;
90
+ const subdirectory = command.options.getString('directory');
91
+ // Validate command against whitelist
92
+ const validation = validateCommand(cmdString);
93
+ if (!validation.valid) {
94
+ await command.reply({
95
+ content: `āŒ ${validation.reason}`,
96
+ ephemeral: true,
97
+ flags: SILENT_MESSAGE_FLAGS,
98
+ });
99
+ return;
100
+ }
101
+ // Get project directory
102
+ let projectDirectory;
103
+ const isThread = [
104
+ ChannelType.PublicThread,
105
+ ChannelType.PrivateThread,
106
+ ChannelType.AnnouncementThread,
107
+ ].includes(channel.type);
108
+ if (isThread) {
109
+ const textChannel = await resolveTextChannel(channel);
110
+ if (textChannel) {
111
+ const config = getChannelDirectory(textChannel.id);
112
+ projectDirectory = config?.directory;
113
+ }
114
+ }
115
+ else if (channel.type === ChannelType.GuildText) {
116
+ const config = getChannelDirectory(channel.id);
117
+ projectDirectory = config?.directory;
118
+ }
119
+ if (!projectDirectory) {
120
+ await command.reply({
121
+ content: 'Could not determine project directory for this channel',
122
+ ephemeral: true,
123
+ flags: SILENT_MESSAGE_FLAGS,
124
+ });
125
+ return;
126
+ }
127
+ const cwd = subdirectory
128
+ ? `${projectDirectory}/${subdirectory}`
129
+ : projectDirectory;
130
+ const timeoutMs = Math.min(timeoutSec * 1000, MAX_TIMEOUT_MS);
131
+ const runConfig = getRunConfig(channel.id);
132
+ const sendableChannel = channel;
133
+ if (background) {
134
+ await command.reply({
135
+ content: `ā³ Running in background: \`${cmdString}\``,
136
+ flags: SILENT_MESSAGE_FLAGS,
137
+ });
138
+ const jobId = `${channel.id}-${Date.now()}`;
139
+ backgroundJobs.set(jobId, {
140
+ command: cmdString,
141
+ startTime: Date.now(),
142
+ threadId: channel.id,
143
+ userId: command.user.id,
144
+ });
145
+ const startTime = Date.now();
146
+ exec(cmdString, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, async (error, stdout, stderr) => {
147
+ const durationMs = Date.now() - startTime;
148
+ const exitCode = error?.code ?? (error ? 1 : 0);
149
+ backgroundJobs.delete(jobId);
150
+ const embed = createResultEmbed({
151
+ command: cmdString,
152
+ stdout,
153
+ stderr,
154
+ exitCode,
155
+ durationMs,
156
+ background: true,
157
+ });
158
+ if (runConfig.notify_discord) {
159
+ try {
160
+ await sendableChannel.send({
161
+ content: `<@${command.user.id}>`,
162
+ embeds: [embed],
163
+ flags: NOTIFY_MESSAGE_FLAGS,
164
+ });
165
+ }
166
+ catch (e) {
167
+ logger.error('Failed to send background result:', e);
168
+ }
169
+ }
170
+ if (runConfig.notify_system) {
171
+ const status = exitCode === 0 ? 'āœ… Success' : 'āŒ Failed';
172
+ await sendSystemNotification({
173
+ title: 'Disunday',
174
+ message: `${status}: ${cmdString.slice(0, 30)}`,
175
+ });
176
+ }
177
+ if (runConfig.webhook_url) {
178
+ await sendWebhook({
179
+ url: runConfig.webhook_url,
180
+ payload: {
181
+ type: 'run_complete',
182
+ command: cmdString,
183
+ exitCode,
184
+ durationMs,
185
+ stdout: stdout.slice(0, 1000),
186
+ stderr: stderr.slice(0, 1000),
187
+ userId: command.user.id,
188
+ channelId: channel.id,
189
+ },
190
+ });
191
+ }
192
+ });
193
+ }
194
+ else {
195
+ // Immediate execution
196
+ await command.deferReply();
197
+ const startTime = Date.now();
198
+ try {
199
+ const { stdout, stderr } = await execAsync(cmdString, {
200
+ cwd,
201
+ timeout: timeoutMs,
202
+ maxBuffer: 10 * 1024 * 1024,
203
+ });
204
+ const durationMs = Date.now() - startTime;
205
+ const embed = createResultEmbed({
206
+ command: cmdString,
207
+ stdout,
208
+ stderr,
209
+ exitCode: 0,
210
+ durationMs,
211
+ });
212
+ await command.editReply({ embeds: [embed] });
213
+ }
214
+ catch (error) {
215
+ const durationMs = Date.now() - startTime;
216
+ const execError = error;
217
+ const embed = createResultEmbed({
218
+ command: cmdString,
219
+ stdout: execError.stdout || '',
220
+ stderr: execError.stderr || (error instanceof Error ? error.message : ''),
221
+ exitCode: execError.killed ? null : (execError.code ?? 1),
222
+ durationMs,
223
+ });
224
+ await command.editReply({ embeds: [embed] });
225
+ }
226
+ }
227
+ }
228
+ export async function handleRunAutocomplete({ interaction, }) {
229
+ const focused = interaction.options.getFocused();
230
+ const whitelist = getBashWhitelist();
231
+ const suggestions = whitelist
232
+ .filter((cmd) => {
233
+ return cmd.toLowerCase().startsWith(focused.toLowerCase());
234
+ })
235
+ .slice(0, 25)
236
+ .map((cmd) => {
237
+ return { name: cmd, value: cmd };
238
+ });
239
+ await interaction.respond(suggestions);
240
+ }
@@ -0,0 +1,170 @@
1
+ import { createScheduledMessage, getSchedulesByChannel, getScheduleById, cancelSchedule, } from '../database.js';
2
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
3
+ import { createLogger, LogPrefix } from '../logger.js';
4
+ const scheduleLogger = createLogger(LogPrefix.INTERACTION);
5
+ function parseTimeInput(input) {
6
+ const now = Date.now();
7
+ const relativeMatch = input.match(/^(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i);
8
+ if (relativeMatch) {
9
+ const value = parseInt(relativeMatch[1], 10);
10
+ const unit = relativeMatch[2].toLowerCase();
11
+ const multipliers = {
12
+ s: 1000,
13
+ sec: 1000,
14
+ second: 1000,
15
+ seconds: 1000,
16
+ m: 60 * 1000,
17
+ min: 60 * 1000,
18
+ minute: 60 * 1000,
19
+ minutes: 60 * 1000,
20
+ h: 60 * 60 * 1000,
21
+ hr: 60 * 60 * 1000,
22
+ hour: 60 * 60 * 1000,
23
+ hours: 60 * 60 * 1000,
24
+ d: 24 * 60 * 60 * 1000,
25
+ day: 24 * 60 * 60 * 1000,
26
+ days: 24 * 60 * 60 * 1000,
27
+ };
28
+ const multiplier = multipliers[unit];
29
+ if (multiplier) {
30
+ return now + value * multiplier;
31
+ }
32
+ }
33
+ const timeMatch = input.match(/^(\d{1,2}):(\d{2})\s*(am|pm)?$/i);
34
+ if (timeMatch) {
35
+ let hours = parseInt(timeMatch[1], 10);
36
+ const minutes = parseInt(timeMatch[2], 10);
37
+ const meridiem = timeMatch[3]?.toLowerCase();
38
+ if (meridiem === 'pm' && hours < 12) {
39
+ hours += 12;
40
+ }
41
+ if (meridiem === 'am' && hours === 12) {
42
+ hours = 0;
43
+ }
44
+ const target = new Date();
45
+ target.setHours(hours, minutes, 0, 0);
46
+ if (target.getTime() <= now) {
47
+ target.setDate(target.getDate() + 1);
48
+ }
49
+ return target.getTime();
50
+ }
51
+ return null;
52
+ }
53
+ function formatScheduleTime(timestamp) {
54
+ const date = new Date(timestamp);
55
+ const now = Date.now();
56
+ const diff = timestamp - now;
57
+ const relative = (() => {
58
+ if (diff < 60000) {
59
+ return `${Math.ceil(diff / 1000)}s`;
60
+ }
61
+ if (diff < 3600000) {
62
+ return `${Math.ceil(diff / 60000)}m`;
63
+ }
64
+ if (diff < 86400000) {
65
+ return `${Math.ceil(diff / 3600000)}h`;
66
+ }
67
+ return `${Math.ceil(diff / 86400000)}d`;
68
+ })();
69
+ const timeStr = date.toLocaleTimeString('en-US', {
70
+ hour: 'numeric',
71
+ minute: '2-digit',
72
+ hour12: true,
73
+ });
74
+ return `${timeStr} (in ${relative})`;
75
+ }
76
+ export async function handleScheduleCommand({ command, appId, }) {
77
+ const subcommand = command.options.getSubcommand();
78
+ switch (subcommand) {
79
+ case 'add': {
80
+ const prompt = command.options.getString('prompt', true);
81
+ const time = command.options.getString('time', true);
82
+ const scheduledAt = parseTimeInput(time);
83
+ if (!scheduledAt) {
84
+ await command.reply({
85
+ content: `āŒ Invalid time format. Use:\n• Relative: \`30m\`, \`2h\`, \`1d\`\n• Absolute: \`3:00pm\`, \`14:30\``,
86
+ ephemeral: true,
87
+ });
88
+ return;
89
+ }
90
+ if (scheduledAt <= Date.now()) {
91
+ await command.reply({
92
+ content: 'āŒ Scheduled time must be in the future',
93
+ ephemeral: true,
94
+ });
95
+ return;
96
+ }
97
+ const channelId = command.channelId;
98
+ const isThread = command.channel?.isThread();
99
+ const threadId = isThread ? command.channelId : undefined;
100
+ const parentChannelId = isThread ? command.channel.parentId : null;
101
+ const id = createScheduledMessage({
102
+ channelId: parentChannelId || channelId,
103
+ threadId,
104
+ prompt,
105
+ scheduledAt,
106
+ createdBy: command.user.id,
107
+ });
108
+ scheduleLogger.log(`[SCHEDULE] Created schedule #${id} for ${formatScheduleTime(scheduledAt)}`);
109
+ await command.reply({
110
+ content: `ā° Scheduled **#${id}** for ${formatScheduleTime(scheduledAt)}\n\`\`\`\n${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}\n\`\`\``,
111
+ flags: SILENT_MESSAGE_FLAGS,
112
+ });
113
+ return;
114
+ }
115
+ case 'list': {
116
+ const channelId = command.channelId;
117
+ const schedules = getSchedulesByChannel(channelId);
118
+ if (schedules.length === 0) {
119
+ await command.reply({
120
+ content: 'šŸ“­ No pending schedules in this channel',
121
+ ephemeral: true,
122
+ });
123
+ return;
124
+ }
125
+ const lines = schedules.map((s) => {
126
+ const time = formatScheduleTime(s.scheduled_at);
127
+ const preview = s.prompt.slice(0, 40) + (s.prompt.length > 40 ? '...' : '');
128
+ return `**#${s.id}** ${time}\nā”” ${preview}`;
129
+ });
130
+ await command.reply({
131
+ content: `šŸ“‹ **Pending Schedules**\n\n${lines.join('\n\n')}`,
132
+ ephemeral: true,
133
+ });
134
+ return;
135
+ }
136
+ case 'cancel': {
137
+ const id = command.options.getInteger('id', true);
138
+ const schedule = getScheduleById(id);
139
+ if (!schedule) {
140
+ await command.reply({
141
+ content: `āŒ Schedule #${id} not found`,
142
+ ephemeral: true,
143
+ });
144
+ return;
145
+ }
146
+ if (schedule.status !== 'pending') {
147
+ await command.reply({
148
+ content: `āŒ Schedule #${id} is already ${schedule.status}`,
149
+ ephemeral: true,
150
+ });
151
+ return;
152
+ }
153
+ const cancelled = cancelSchedule(id, command.user.id);
154
+ if (cancelled) {
155
+ scheduleLogger.log(`[SCHEDULE] Cancelled schedule #${id}`);
156
+ await command.reply({
157
+ content: `āœ… Cancelled schedule **#${id}**`,
158
+ flags: SILENT_MESSAGE_FLAGS,
159
+ });
160
+ }
161
+ else {
162
+ await command.reply({
163
+ content: `āŒ Failed to cancel schedule #${id}`,
164
+ ephemeral: true,
165
+ });
166
+ }
167
+ return;
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,58 @@
1
+ import { ChannelType } from 'discord.js';
2
+ import { getDatabase, getThreadWorktree } from '../database.js';
3
+ import { resolveTextChannel, getDisundayMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
4
+ export async function handleSessionInfoCommand({ command, }) {
5
+ const channel = command.channel;
6
+ if (!channel) {
7
+ await command.reply({
8
+ content: 'This command can only be used in a channel',
9
+ ephemeral: true,
10
+ flags: SILENT_MESSAGE_FLAGS,
11
+ });
12
+ return;
13
+ }
14
+ const isThread = [
15
+ ChannelType.PublicThread,
16
+ ChannelType.PrivateThread,
17
+ ChannelType.AnnouncementThread,
18
+ ].includes(channel.type);
19
+ if (!isThread) {
20
+ await command.reply({
21
+ content: 'This command can only be used in a thread with an active session',
22
+ ephemeral: true,
23
+ flags: SILENT_MESSAGE_FLAGS,
24
+ });
25
+ return;
26
+ }
27
+ const textChannel = await resolveTextChannel(channel);
28
+ const { projectDirectory: directory } = getDisundayMetadata(textChannel);
29
+ if (!directory) {
30
+ await command.reply({
31
+ content: 'Could not determine project directory for this channel',
32
+ ephemeral: true,
33
+ flags: SILENT_MESSAGE_FLAGS,
34
+ });
35
+ return;
36
+ }
37
+ const row = getDatabase()
38
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
39
+ .get(channel.id);
40
+ if (!row?.session_id) {
41
+ await command.reply({
42
+ content: 'No active session in this thread',
43
+ ephemeral: true,
44
+ flags: SILENT_MESSAGE_FLAGS,
45
+ });
46
+ return;
47
+ }
48
+ const sessionId = row.session_id;
49
+ const worktreeInfo = getThreadWorktree(channel.id);
50
+ const sdkDirectory = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
51
+ ? worktreeInfo.worktree_directory
52
+ : directory;
53
+ const terminalCmd = `opencode -s ${sessionId} ${sdkDirectory}`;
54
+ await command.reply({
55
+ content: `šŸ“‹ **Session Info**\n\n**Session ID:** \`${sessionId}\`\n**Directory:** \`${sdkDirectory}\`\n\n**Terminal command:**\n\`\`\`\n${terminalCmd}\n\`\`\``,
56
+ flags: SILENT_MESSAGE_FLAGS,
57
+ });
58
+ }
@@ -0,0 +1,191 @@
1
+ // /new-session command - Start a new OpenCode session.
2
+ import { ChannelType } from 'discord.js';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { getDatabase, getChannelDirectory } from '../database.js';
6
+ import { initializeOpencodeForDirectory } from '../opencode.js';
7
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
8
+ import { handleOpencodeSession } from '../session-handler.js';
9
+ import { createLogger, LogPrefix } from '../logger.js';
10
+ import * as errore from 'errore';
11
+ const logger = createLogger(LogPrefix.SESSION);
12
+ export async function handleSessionCommand({ command, appId }) {
13
+ await command.deferReply({ ephemeral: false });
14
+ const prompt = command.options.getString('prompt', true);
15
+ const filesString = command.options.getString('files') || '';
16
+ const agent = command.options.getString('agent') || undefined;
17
+ const channel = command.channel;
18
+ if (!channel || channel.type !== ChannelType.GuildText) {
19
+ await command.editReply('This command can only be used in text channels');
20
+ return;
21
+ }
22
+ const textChannel = channel;
23
+ const channelConfig = getChannelDirectory(textChannel.id);
24
+ const projectDirectory = channelConfig?.directory;
25
+ const channelAppId = channelConfig?.appId || undefined;
26
+ if (channelAppId && channelAppId !== appId) {
27
+ await command.editReply('This channel is not configured for this bot');
28
+ return;
29
+ }
30
+ if (!projectDirectory) {
31
+ await command.editReply('This channel is not configured with a project directory');
32
+ return;
33
+ }
34
+ if (!fs.existsSync(projectDirectory)) {
35
+ await command.editReply(`Directory does not exist: ${projectDirectory}`);
36
+ return;
37
+ }
38
+ try {
39
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
40
+ if (getClient instanceof Error) {
41
+ await command.editReply(getClient.message);
42
+ return;
43
+ }
44
+ const files = filesString
45
+ .split(',')
46
+ .map((f) => f.trim())
47
+ .filter((f) => f);
48
+ let fullPrompt = prompt;
49
+ if (files.length > 0) {
50
+ fullPrompt = `${prompt}\n\n@${files.join(' @')}`;
51
+ }
52
+ const starterMessage = await textChannel.send({
53
+ content: `šŸš€ **Starting OpenCode session**\nšŸ“ ${prompt}${files.length > 0 ? `\nšŸ“Ž Files: ${files.join(', ')}` : ''}`,
54
+ flags: SILENT_MESSAGE_FLAGS,
55
+ });
56
+ const thread = await starterMessage.startThread({
57
+ name: prompt.slice(0, 100),
58
+ autoArchiveDuration: 1440,
59
+ reason: 'OpenCode session',
60
+ });
61
+ // Add user to thread so it appears in their sidebar
62
+ await thread.members.add(command.user.id);
63
+ await command.editReply(`Created new session in ${thread.toString()}`);
64
+ await handleOpencodeSession({
65
+ prompt: fullPrompt,
66
+ thread,
67
+ projectDirectory,
68
+ channelId: textChannel.id,
69
+ agent,
70
+ });
71
+ }
72
+ catch (error) {
73
+ logger.error('[SESSION] Error:', error);
74
+ await command.editReply(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
75
+ }
76
+ }
77
+ async function handleAgentAutocomplete({ interaction, appId }) {
78
+ const focusedValue = interaction.options.getFocused();
79
+ let projectDirectory;
80
+ if (interaction.channel && interaction.channel.type === ChannelType.GuildText) {
81
+ const channelConfig = getChannelDirectory(interaction.channel.id);
82
+ if (channelConfig) {
83
+ if (channelConfig.appId && channelConfig.appId !== appId) {
84
+ await interaction.respond([]);
85
+ return;
86
+ }
87
+ projectDirectory = channelConfig.directory;
88
+ }
89
+ }
90
+ if (!projectDirectory) {
91
+ await interaction.respond([]);
92
+ return;
93
+ }
94
+ try {
95
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
96
+ if (getClient instanceof Error) {
97
+ await interaction.respond([]);
98
+ return;
99
+ }
100
+ const agentsResponse = await getClient().app.agents({
101
+ query: { directory: projectDirectory },
102
+ });
103
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
104
+ await interaction.respond([]);
105
+ return;
106
+ }
107
+ const agents = agentsResponse.data
108
+ .filter((a) => {
109
+ const hidden = a.hidden;
110
+ return (a.mode === 'primary' || a.mode === 'all') && !hidden;
111
+ })
112
+ .filter((a) => a.name.toLowerCase().includes(focusedValue.toLowerCase()))
113
+ .slice(0, 25);
114
+ const choices = agents.map((agent) => ({
115
+ name: agent.name.slice(0, 100),
116
+ value: agent.name,
117
+ }));
118
+ await interaction.respond(choices);
119
+ }
120
+ catch (error) {
121
+ logger.error('[AUTOCOMPLETE] Error fetching agents:', error);
122
+ await interaction.respond([]);
123
+ }
124
+ }
125
+ export async function handleSessionAutocomplete({ interaction, appId, }) {
126
+ const focusedOption = interaction.options.getFocused(true);
127
+ if (focusedOption.name === 'agent') {
128
+ await handleAgentAutocomplete({ interaction, appId });
129
+ return;
130
+ }
131
+ if (focusedOption.name !== 'files') {
132
+ return;
133
+ }
134
+ const focusedValue = focusedOption.value;
135
+ const parts = focusedValue.split(',');
136
+ const previousFiles = parts
137
+ .slice(0, -1)
138
+ .map((f) => f.trim())
139
+ .filter((f) => f);
140
+ const currentQuery = (parts[parts.length - 1] || '').trim();
141
+ let projectDirectory;
142
+ if (interaction.channel && interaction.channel.type === ChannelType.GuildText) {
143
+ const channelConfig = getChannelDirectory(interaction.channel.id);
144
+ if (channelConfig) {
145
+ if (channelConfig.appId && channelConfig.appId !== appId) {
146
+ await interaction.respond([]);
147
+ return;
148
+ }
149
+ projectDirectory = channelConfig.directory;
150
+ }
151
+ }
152
+ if (!projectDirectory) {
153
+ await interaction.respond([]);
154
+ return;
155
+ }
156
+ try {
157
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
158
+ if (getClient instanceof Error) {
159
+ await interaction.respond([]);
160
+ return;
161
+ }
162
+ const response = await getClient().find.files({
163
+ query: {
164
+ query: currentQuery || '',
165
+ },
166
+ });
167
+ const files = response.data || [];
168
+ const prefix = previousFiles.length > 0 ? previousFiles.join(', ') + ', ' : '';
169
+ const choices = files
170
+ .map((file) => {
171
+ const fullValue = prefix + file;
172
+ const allFiles = [...previousFiles, file];
173
+ const allBasenames = allFiles.map((f) => f.split('/').pop() || f);
174
+ let displayName = allBasenames.join(', ');
175
+ if (displayName.length > 100) {
176
+ displayName = '…' + displayName.slice(-97);
177
+ }
178
+ return {
179
+ name: displayName,
180
+ value: fullValue,
181
+ };
182
+ })
183
+ .filter((choice) => choice.value.length <= 100)
184
+ .slice(0, 25);
185
+ await interaction.respond(choices);
186
+ }
187
+ catch (error) {
188
+ logger.error('[AUTOCOMPLETE] Error fetching files:', error);
189
+ await interaction.respond([]);
190
+ }
191
+ }