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,488 @@
1
+ // /login command - Authenticate with AI providers (OAuth or API key).
2
+ // Supports GitHub Copilot (device flow), OpenAI Codex (device flow), and API keys.
3
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ModalSubmitInteraction, ChannelType, } from 'discord.js';
4
+ import crypto from 'node:crypto';
5
+ import { initializeOpencodeForDirectory } from '../opencode.js';
6
+ import { resolveTextChannel, getDisundayMetadata } from '../discord-utils.js';
7
+ import { createLogger, LogPrefix } from '../logger.js';
8
+ const loginLogger = createLogger(LogPrefix.LOGIN);
9
+ // Store context by hash to avoid customId length limits (Discord max: 100 chars)
10
+ const pendingLoginContexts = new Map();
11
+ /**
12
+ * Handle the /login slash command.
13
+ * Shows a select menu with available providers.
14
+ */
15
+ export async function handleLoginCommand({ interaction, appId, }) {
16
+ loginLogger.log('[LOGIN] handleLoginCommand called');
17
+ // Defer reply immediately to avoid 3-second timeout
18
+ await interaction.deferReply({ ephemeral: true });
19
+ loginLogger.log('[LOGIN] Deferred reply');
20
+ const channel = interaction.channel;
21
+ if (!channel) {
22
+ await interaction.editReply({
23
+ content: 'This command can only be used in a channel',
24
+ });
25
+ return;
26
+ }
27
+ // Determine if we're in a thread or text channel
28
+ const isThread = [
29
+ ChannelType.PublicThread,
30
+ ChannelType.PrivateThread,
31
+ ChannelType.AnnouncementThread,
32
+ ].includes(channel.type);
33
+ let projectDirectory;
34
+ let channelAppId;
35
+ let targetChannelId;
36
+ if (isThread) {
37
+ const thread = channel;
38
+ const textChannel = await resolveTextChannel(thread);
39
+ const metadata = getDisundayMetadata(textChannel);
40
+ projectDirectory = metadata.projectDirectory;
41
+ channelAppId = metadata.channelAppId;
42
+ targetChannelId = textChannel?.id || channel.id;
43
+ }
44
+ else if (channel.type === ChannelType.GuildText) {
45
+ const textChannel = channel;
46
+ const metadata = getDisundayMetadata(textChannel);
47
+ projectDirectory = metadata.projectDirectory;
48
+ channelAppId = metadata.channelAppId;
49
+ targetChannelId = channel.id;
50
+ }
51
+ else {
52
+ await interaction.editReply({
53
+ content: 'This command can only be used in text channels or threads',
54
+ });
55
+ return;
56
+ }
57
+ if (channelAppId && channelAppId !== appId) {
58
+ await interaction.editReply({
59
+ content: 'This channel is not configured for this bot',
60
+ });
61
+ return;
62
+ }
63
+ if (!projectDirectory) {
64
+ await interaction.editReply({
65
+ content: 'This channel is not configured with a project directory',
66
+ });
67
+ return;
68
+ }
69
+ try {
70
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
71
+ if (getClient instanceof Error) {
72
+ await interaction.editReply({ content: getClient.message });
73
+ return;
74
+ }
75
+ const providersResponse = await getClient().provider.list({
76
+ query: { directory: projectDirectory },
77
+ });
78
+ if (!providersResponse.data) {
79
+ await interaction.editReply({
80
+ content: 'Failed to fetch providers',
81
+ });
82
+ return;
83
+ }
84
+ const { all: allProviders, connected } = providersResponse.data;
85
+ if (allProviders.length === 0) {
86
+ await interaction.editReply({
87
+ content: 'No providers available.',
88
+ });
89
+ return;
90
+ }
91
+ // Store context with a short hash key to avoid customId length limits
92
+ const context = {
93
+ dir: projectDirectory,
94
+ channelId: targetChannelId,
95
+ };
96
+ const contextHash = crypto.randomBytes(8).toString('hex');
97
+ pendingLoginContexts.set(contextHash, context);
98
+ const options = allProviders.slice(0, 25).map((provider) => {
99
+ const isConnected = connected.includes(provider.id);
100
+ return {
101
+ label: `${provider.name}${isConnected ? ' ✓' : ''}`.slice(0, 100),
102
+ value: provider.id,
103
+ description: isConnected ? 'Connected - select to re-authenticate' : 'Not connected',
104
+ };
105
+ });
106
+ const selectMenu = new StringSelectMenuBuilder()
107
+ .setCustomId(`login_provider:${contextHash}`)
108
+ .setPlaceholder('Select a provider to authenticate')
109
+ .addOptions(options);
110
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
111
+ await interaction.editReply({
112
+ content: '**Authenticate with Provider**\nSelect a provider:',
113
+ components: [actionRow],
114
+ });
115
+ }
116
+ catch (error) {
117
+ loginLogger.error('Error loading providers:', error);
118
+ await interaction.editReply({
119
+ content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
120
+ });
121
+ }
122
+ }
123
+ /**
124
+ * Handle the provider select menu interaction.
125
+ * Shows a second select menu with auth methods for the chosen provider.
126
+ */
127
+ export async function handleLoginProviderSelectMenu(interaction) {
128
+ const customId = interaction.customId;
129
+ if (!customId.startsWith('login_provider:')) {
130
+ return;
131
+ }
132
+ const contextHash = customId.replace('login_provider:', '');
133
+ const context = pendingLoginContexts.get(contextHash);
134
+ if (!context) {
135
+ await interaction.deferUpdate();
136
+ await interaction.editReply({
137
+ content: 'Selection expired. Please run /login again.',
138
+ components: [],
139
+ });
140
+ return;
141
+ }
142
+ const selectedProviderId = interaction.values[0];
143
+ if (!selectedProviderId) {
144
+ await interaction.deferUpdate();
145
+ await interaction.editReply({
146
+ content: 'No provider selected',
147
+ components: [],
148
+ });
149
+ return;
150
+ }
151
+ try {
152
+ const getClient = await initializeOpencodeForDirectory(context.dir);
153
+ if (getClient instanceof Error) {
154
+ await interaction.deferUpdate();
155
+ await interaction.editReply({
156
+ content: getClient.message,
157
+ components: [],
158
+ });
159
+ return;
160
+ }
161
+ // Get provider info for display
162
+ const providersResponse = await getClient().provider.list({
163
+ query: { directory: context.dir },
164
+ });
165
+ const provider = providersResponse.data?.all.find((p) => p.id === selectedProviderId);
166
+ const providerName = provider?.name || selectedProviderId;
167
+ // Get auth methods for all providers
168
+ const authMethodsResponse = await getClient().provider.auth({
169
+ query: { directory: context.dir },
170
+ });
171
+ if (!authMethodsResponse.data) {
172
+ await interaction.deferUpdate();
173
+ await interaction.editReply({
174
+ content: 'Failed to fetch authentication methods',
175
+ components: [],
176
+ });
177
+ return;
178
+ }
179
+ // Get methods for this specific provider, default to API key if none defined
180
+ const methods = authMethodsResponse.data[selectedProviderId] || [
181
+ { type: 'api', label: 'API Key' },
182
+ ];
183
+ if (methods.length === 0) {
184
+ await interaction.deferUpdate();
185
+ await interaction.editReply({
186
+ content: `No authentication methods available for ${providerName}`,
187
+ components: [],
188
+ });
189
+ return;
190
+ }
191
+ // Update context with provider info
192
+ context.providerId = selectedProviderId;
193
+ context.providerName = providerName;
194
+ pendingLoginContexts.set(contextHash, context);
195
+ // If only one method and it's API, show modal directly (no defer)
196
+ if (methods.length === 1 && methods[0].type === 'api') {
197
+ const method = methods[0];
198
+ context.methodIndex = 0;
199
+ context.methodType = method.type;
200
+ context.methodLabel = method.label;
201
+ pendingLoginContexts.set(contextHash, context);
202
+ await showApiKeyModal(interaction, contextHash, providerName);
203
+ return;
204
+ }
205
+ // For OAuth or multiple methods, defer and continue
206
+ await interaction.deferUpdate();
207
+ // If only one method and it's OAuth, start flow directly
208
+ if (methods.length === 1) {
209
+ const method = methods[0];
210
+ context.methodIndex = 0;
211
+ context.methodType = method.type;
212
+ context.methodLabel = method.label;
213
+ pendingLoginContexts.set(contextHash, context);
214
+ await startOAuthFlow(interaction, context, contextHash);
215
+ return;
216
+ }
217
+ // Multiple methods - show selection menu
218
+ const options = methods.slice(0, 25).map((method, index) => ({
219
+ label: method.label.slice(0, 100),
220
+ value: String(index),
221
+ description: method.type === 'oauth' ? 'OAuth authentication' : 'Enter API key manually',
222
+ }));
223
+ const selectMenu = new StringSelectMenuBuilder()
224
+ .setCustomId(`login_method:${contextHash}`)
225
+ .setPlaceholder('Select authentication method')
226
+ .addOptions(options);
227
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
228
+ await interaction.editReply({
229
+ content: `**Authenticate with ${providerName}**\nSelect authentication method:`,
230
+ components: [actionRow],
231
+ });
232
+ }
233
+ catch (error) {
234
+ loginLogger.error('Error loading auth methods:', error);
235
+ if (!interaction.deferred && !interaction.replied) {
236
+ await interaction.deferUpdate();
237
+ }
238
+ await interaction.editReply({
239
+ content: `Failed to load auth methods: ${error instanceof Error ? error.message : 'Unknown error'}`,
240
+ components: [],
241
+ });
242
+ }
243
+ }
244
+ /**
245
+ * Handle the auth method select menu interaction.
246
+ * Starts OAuth flow or shows API key modal.
247
+ */
248
+ export async function handleLoginMethodSelectMenu(interaction) {
249
+ const customId = interaction.customId;
250
+ if (!customId.startsWith('login_method:')) {
251
+ return;
252
+ }
253
+ const contextHash = customId.replace('login_method:', '');
254
+ const context = pendingLoginContexts.get(contextHash);
255
+ if (!context || !context.providerId || !context.providerName) {
256
+ await interaction.deferUpdate();
257
+ await interaction.editReply({
258
+ content: 'Selection expired. Please run /login again.',
259
+ components: [],
260
+ });
261
+ return;
262
+ }
263
+ const selectedMethodIndex = parseInt(interaction.values[0] || '0', 10);
264
+ try {
265
+ const getClient = await initializeOpencodeForDirectory(context.dir);
266
+ if (getClient instanceof Error) {
267
+ await interaction.deferUpdate();
268
+ await interaction.editReply({
269
+ content: getClient.message,
270
+ components: [],
271
+ });
272
+ return;
273
+ }
274
+ // Get auth methods again to get the selected one
275
+ const authMethodsResponse = await getClient().provider.auth({
276
+ query: { directory: context.dir },
277
+ });
278
+ const methods = authMethodsResponse.data?.[context.providerId] || [
279
+ { type: 'api', label: 'API Key' },
280
+ ];
281
+ const selectedMethod = methods[selectedMethodIndex];
282
+ if (!selectedMethod) {
283
+ await interaction.deferUpdate();
284
+ await interaction.editReply({
285
+ content: 'Invalid method selected',
286
+ components: [],
287
+ });
288
+ return;
289
+ }
290
+ // Update context
291
+ context.methodIndex = selectedMethodIndex;
292
+ context.methodType = selectedMethod.type;
293
+ context.methodLabel = selectedMethod.label;
294
+ pendingLoginContexts.set(contextHash, context);
295
+ if (selectedMethod.type === 'api') {
296
+ // Show API key modal (don't defer for modals)
297
+ await showApiKeyModal(interaction, contextHash, context.providerName);
298
+ }
299
+ else {
300
+ // Start OAuth flow
301
+ await interaction.deferUpdate();
302
+ await startOAuthFlow(interaction, context, contextHash);
303
+ }
304
+ }
305
+ catch (error) {
306
+ loginLogger.error('Error processing auth method:', error);
307
+ try {
308
+ if (!interaction.deferred && !interaction.replied) {
309
+ await interaction.deferUpdate();
310
+ }
311
+ await interaction.editReply({
312
+ content: `Failed to process auth method: ${error instanceof Error ? error.message : 'Unknown error'}`,
313
+ components: [],
314
+ });
315
+ }
316
+ catch {
317
+ // Ignore follow-up errors
318
+ }
319
+ }
320
+ }
321
+ /**
322
+ * Show API key input modal.
323
+ */
324
+ async function showApiKeyModal(interaction, contextHash, providerName) {
325
+ const modal = new ModalBuilder()
326
+ .setCustomId(`login_apikey:${contextHash}`)
327
+ .setTitle(`${providerName} API Key`.slice(0, 45));
328
+ const apiKeyInput = new TextInputBuilder()
329
+ .setCustomId('apikey')
330
+ .setLabel('API Key')
331
+ .setPlaceholder('sk-...')
332
+ .setStyle(TextInputStyle.Short)
333
+ .setRequired(true);
334
+ const actionRow = new ActionRowBuilder().addComponents(apiKeyInput);
335
+ modal.addComponents(actionRow);
336
+ await interaction.showModal(modal);
337
+ }
338
+ /**
339
+ * Start OAuth authorization flow.
340
+ */
341
+ async function startOAuthFlow(interaction, context, contextHash) {
342
+ if (!context.providerId || context.methodIndex === undefined) {
343
+ await interaction.editReply({
344
+ content: 'Invalid context for OAuth flow',
345
+ components: [],
346
+ });
347
+ return;
348
+ }
349
+ try {
350
+ const getClient = await initializeOpencodeForDirectory(context.dir);
351
+ if (getClient instanceof Error) {
352
+ await interaction.editReply({
353
+ content: getClient.message,
354
+ components: [],
355
+ });
356
+ return;
357
+ }
358
+ await interaction.editReply({
359
+ content: `**Authenticating with ${context.providerName}**\nStarting authorization...`,
360
+ components: [],
361
+ });
362
+ // Start OAuth authorization
363
+ const authorizeResponse = await getClient().provider.oauth.authorize({
364
+ path: { id: context.providerId },
365
+ body: { method: context.methodIndex },
366
+ query: { directory: context.dir },
367
+ });
368
+ if (!authorizeResponse.data) {
369
+ const errorData = authorizeResponse.error;
370
+ await interaction.editReply({
371
+ content: `Failed to start authorization: ${errorData?.data?.message || 'Unknown error'}`,
372
+ components: [],
373
+ });
374
+ return;
375
+ }
376
+ const { url, method, instructions } = authorizeResponse.data;
377
+ // Show authorization URL and instructions
378
+ let message = `**Authenticating with ${context.providerName}**\n\n`;
379
+ message += `Open this URL to authorize:\n${url}\n\n`;
380
+ if (instructions) {
381
+ // Extract code from instructions like "Enter code: ABC-123"
382
+ const codeMatch = instructions.match(/code[:\s]+([A-Z0-9-]+)/i);
383
+ if (codeMatch) {
384
+ message += `**Code:** \`${codeMatch[1]}\`\n\n`;
385
+ }
386
+ else {
387
+ message += `${instructions}\n\n`;
388
+ }
389
+ }
390
+ if (method === 'auto') {
391
+ message += '_Waiting for authorization to complete..._';
392
+ }
393
+ await interaction.editReply({
394
+ content: message,
395
+ components: [],
396
+ });
397
+ if (method === 'auto') {
398
+ // Poll for completion (device flow)
399
+ const callbackResponse = await getClient().provider.oauth.callback({
400
+ path: { id: context.providerId },
401
+ body: { method: context.methodIndex },
402
+ query: { directory: context.dir },
403
+ });
404
+ if (callbackResponse.error) {
405
+ const errorData = callbackResponse.error;
406
+ await interaction.editReply({
407
+ content: `**Authentication Failed**\n${errorData?.data?.message || 'Authorization was not completed'}`,
408
+ components: [],
409
+ });
410
+ return;
411
+ }
412
+ // Dispose to refresh provider state so new credentials are recognized
413
+ await getClient().instance.dispose({ query: { directory: context.dir } });
414
+ await interaction.editReply({
415
+ content: `✅ **Successfully authenticated with ${context.providerName}!**\n\nYou can now use models from this provider.`,
416
+ components: [],
417
+ });
418
+ }
419
+ // For 'code' method, we would need to prompt for code input
420
+ // But Discord modals can't be shown after deferUpdate, so we'd need a different flow
421
+ // For now, most providers use 'auto' (device flow) which works well for Discord
422
+ // Clean up context
423
+ pendingLoginContexts.delete(contextHash);
424
+ }
425
+ catch (error) {
426
+ loginLogger.error('OAuth flow error:', error);
427
+ await interaction.editReply({
428
+ content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
429
+ components: [],
430
+ });
431
+ }
432
+ }
433
+ /**
434
+ * Handle API key modal submission.
435
+ */
436
+ export async function handleApiKeyModalSubmit(interaction) {
437
+ const customId = interaction.customId;
438
+ if (!customId.startsWith('login_apikey:')) {
439
+ return;
440
+ }
441
+ await interaction.deferReply({ ephemeral: true });
442
+ const contextHash = customId.replace('login_apikey:', '');
443
+ const context = pendingLoginContexts.get(contextHash);
444
+ if (!context || !context.providerId || !context.providerName) {
445
+ await interaction.editReply({
446
+ content: 'Session expired. Please run /login again.',
447
+ });
448
+ return;
449
+ }
450
+ const apiKey = interaction.fields.getTextInputValue('apikey');
451
+ if (!apiKey?.trim()) {
452
+ await interaction.editReply({
453
+ content: 'API key is required.',
454
+ });
455
+ return;
456
+ }
457
+ try {
458
+ const getClient = await initializeOpencodeForDirectory(context.dir);
459
+ if (getClient instanceof Error) {
460
+ await interaction.editReply({
461
+ content: getClient.message,
462
+ });
463
+ return;
464
+ }
465
+ // Set the API key
466
+ await getClient().auth.set({
467
+ path: { id: context.providerId },
468
+ body: {
469
+ type: 'api',
470
+ key: apiKey.trim(),
471
+ },
472
+ query: { directory: context.dir },
473
+ });
474
+ // Dispose to refresh provider state so new credentials are recognized
475
+ await getClient().instance.dispose({ query: { directory: context.dir } });
476
+ await interaction.editReply({
477
+ content: `✅ **Successfully authenticated with ${context.providerName}!**\n\nYou can now use models from this provider.`,
478
+ });
479
+ // Clean up context
480
+ pendingLoginContexts.delete(contextHash);
481
+ }
482
+ catch (error) {
483
+ loginLogger.error('API key save error:', error);
484
+ await interaction.editReply({
485
+ content: `**Failed to save API key**\n${error instanceof Error ? error.message : 'Unknown error'}`,
486
+ });
487
+ }
488
+ }
@@ -0,0 +1,165 @@
1
+ // /merge-worktree command - Merge worktree commits into main/default branch.
2
+ // Handles both branch-based worktrees and detached HEAD state.
3
+ // After merge, switches to detached HEAD at main so user can keep working.
4
+ import {} from 'discord.js';
5
+ import { getThreadWorktree } from '../database.js';
6
+ import { createLogger, LogPrefix } from '../logger.js';
7
+ import { execAsync } from '../worktree-utils.js';
8
+ const logger = createLogger(LogPrefix.WORKTREE);
9
+ /** Worktree thread title prefix - indicates unmerged worktree */
10
+ export const WORKTREE_PREFIX = '⬦ ';
11
+ /**
12
+ * Remove the worktree prefix from a thread title.
13
+ * Uses Promise.race with timeout since Discord thread title updates can hang.
14
+ */
15
+ async function removeWorktreePrefixFromTitle(thread) {
16
+ if (!thread.name.startsWith(WORKTREE_PREFIX)) {
17
+ return;
18
+ }
19
+ const newName = thread.name.slice(WORKTREE_PREFIX.length);
20
+ // Race between the edit and a timeout - thread title updates are heavily rate-limited
21
+ const timeoutMs = 5000;
22
+ const editPromise = thread.setName(newName).catch((e) => {
23
+ logger.warn(`Failed to update thread title: ${e instanceof Error ? e.message : String(e)}`);
24
+ });
25
+ const timeoutPromise = new Promise((resolve) => {
26
+ setTimeout(() => {
27
+ logger.warn(`Thread title update timed out after ${timeoutMs}ms`);
28
+ resolve();
29
+ }, timeoutMs);
30
+ });
31
+ await Promise.race([editPromise, timeoutPromise]);
32
+ }
33
+ /**
34
+ * Check if worktree is in detached HEAD state.
35
+ */
36
+ async function isDetachedHead(worktreeDir) {
37
+ try {
38
+ await execAsync(`git -C "${worktreeDir}" symbolic-ref HEAD`);
39
+ return false;
40
+ }
41
+ catch (error) {
42
+ logger.debug(`Failed to resolve HEAD for ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
43
+ return true;
44
+ }
45
+ }
46
+ /**
47
+ * Get current branch name (returns null if detached).
48
+ */
49
+ async function getCurrentBranch(worktreeDir) {
50
+ try {
51
+ const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`);
52
+ return stdout.trim() || null;
53
+ }
54
+ catch (error) {
55
+ logger.debug(`Failed to get current branch for ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
56
+ return null;
57
+ }
58
+ }
59
+ export async function handleMergeWorktreeCommand({ command, appId }) {
60
+ await command.deferReply({ ephemeral: false });
61
+ const channel = command.channel;
62
+ // Must be in a thread
63
+ if (!channel || !channel.isThread()) {
64
+ await command.editReply('This command can only be used in a thread');
65
+ return;
66
+ }
67
+ const thread = channel;
68
+ // Get worktree info from database
69
+ const worktreeInfo = getThreadWorktree(thread.id);
70
+ if (!worktreeInfo) {
71
+ await command.editReply('This thread is not associated with a worktree');
72
+ return;
73
+ }
74
+ if (worktreeInfo.status !== 'ready' || !worktreeInfo.worktree_directory) {
75
+ await command.editReply(`Worktree is not ready (status: ${worktreeInfo.status})${worktreeInfo.error_message ? `: ${worktreeInfo.error_message}` : ''}`);
76
+ return;
77
+ }
78
+ const mainRepoDir = worktreeInfo.project_directory;
79
+ const worktreeDir = worktreeInfo.worktree_directory;
80
+ try {
81
+ // 1. Check for uncommitted changes
82
+ const { stdout: status } = await execAsync(`git -C "${worktreeDir}" status --porcelain`);
83
+ if (status.trim()) {
84
+ await command.editReply(`❌ Uncommitted changes detected in worktree.\n\nPlease commit your changes first, then retry \`/merge-worktree\`.`);
85
+ return;
86
+ }
87
+ // 2. Get the default branch name
88
+ logger.log(`Getting default branch for ${mainRepoDir}`);
89
+ let defaultBranch;
90
+ try {
91
+ const { stdout } = await execAsync(`git -C "${mainRepoDir}" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@'`);
92
+ defaultBranch = stdout.trim() || 'main';
93
+ }
94
+ catch (error) {
95
+ logger.warn(`Failed to detect default branch for ${mainRepoDir}, falling back to main:`, error instanceof Error ? error.message : String(error));
96
+ defaultBranch = 'main';
97
+ }
98
+ // 3. Determine if we're on a branch or detached HEAD
99
+ const isDetached = await isDetachedHead(worktreeDir);
100
+ const currentBranch = await getCurrentBranch(worktreeDir);
101
+ let branchToMerge;
102
+ let tempBranch = null;
103
+ if (isDetached) {
104
+ // Create a temporary branch from detached HEAD
105
+ tempBranch = `temp-merge-${Date.now()}`;
106
+ logger.log(`Detached HEAD detected, creating temp branch: ${tempBranch}`);
107
+ await execAsync(`git -C "${worktreeDir}" checkout -b ${tempBranch}`);
108
+ branchToMerge = tempBranch;
109
+ }
110
+ else {
111
+ branchToMerge = currentBranch || worktreeInfo.worktree_name;
112
+ }
113
+ logger.log(`Default branch: ${defaultBranch}, branch to merge: ${branchToMerge}`);
114
+ // 4. Merge default branch INTO worktree (handles diverged branches)
115
+ logger.log(`Merging ${defaultBranch} into worktree at ${worktreeDir}`);
116
+ try {
117
+ await execAsync(`git -C "${worktreeDir}" merge ${defaultBranch} --no-edit`);
118
+ }
119
+ catch (e) {
120
+ // If merge fails (conflicts), abort and report
121
+ await execAsync(`git -C "${worktreeDir}" merge --abort`).catch((error) => {
122
+ logger.warn(`Failed to abort merge in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
123
+ });
124
+ // Clean up temp branch if we created one
125
+ if (tempBranch) {
126
+ await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch((error) => {
127
+ logger.warn(`Failed to detach HEAD after merge conflict in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
128
+ });
129
+ await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch((error) => {
130
+ logger.warn(`Failed to delete temp branch ${tempBranch} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
131
+ });
132
+ }
133
+ throw new Error(`Merge conflict - resolve manually in worktree then retry`);
134
+ }
135
+ // 5. Update default branch ref to point to current HEAD
136
+ // Use update-ref instead of fetch because fetch refuses if branch is checked out
137
+ logger.log(`Updating ${defaultBranch} to point to current HEAD`);
138
+ const { stdout: commitHash } = await execAsync(`git -C "${worktreeDir}" rev-parse HEAD`);
139
+ await execAsync(`git -C "${mainRepoDir}" update-ref refs/heads/${defaultBranch} ${commitHash.trim()}`);
140
+ // 6. Switch to detached HEAD at default branch (allows main to be checked out elsewhere)
141
+ logger.log(`Switching to detached HEAD at ${defaultBranch}`);
142
+ await execAsync(`git -C "${worktreeDir}" checkout --detach ${defaultBranch}`);
143
+ // 7. Delete the merged branch (temp or original)
144
+ logger.log(`Deleting merged branch ${branchToMerge}`);
145
+ await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch((error) => {
146
+ logger.warn(`Failed to delete merged branch ${branchToMerge} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
147
+ });
148
+ // Also delete the original worktree branch if different from what we merged
149
+ if (!isDetached && branchToMerge !== worktreeInfo.worktree_name) {
150
+ await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch((error) => {
151
+ logger.warn(`Failed to delete worktree branch ${worktreeInfo.worktree_name} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
152
+ });
153
+ }
154
+ // 8. Remove worktree prefix from thread title (fire and forget with timeout)
155
+ void removeWorktreePrefixFromTitle(thread);
156
+ const sourceDesc = isDetached ? 'detached commits' : `\`${branchToMerge}\``;
157
+ await command.editReply(`✅ Merged ${sourceDesc} into \`${defaultBranch}\`\n\nWorktree now at detached HEAD - you can keep working here.`);
158
+ logger.log(`Successfully merged ${branchToMerge} into ${defaultBranch}`);
159
+ }
160
+ catch (e) {
161
+ const errorMsg = e instanceof Error ? e.message : String(e);
162
+ logger.error(`Merge failed: ${errorMsg}`);
163
+ await command.editReply(`❌ Merge failed:\n\`\`\`\n${errorMsg}\n\`\`\``);
164
+ }
165
+ }