kimaki 0.4.43 → 0.4.45

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 (47) hide show
  1. package/dist/channel-management.js +6 -15
  2. package/dist/cli.js +210 -32
  3. package/dist/commands/merge-worktree.js +152 -0
  4. package/dist/commands/permissions.js +21 -5
  5. package/dist/commands/queue.js +5 -1
  6. package/dist/commands/resume.js +8 -16
  7. package/dist/commands/session.js +18 -42
  8. package/dist/commands/user-command.js +8 -17
  9. package/dist/commands/verbosity.js +53 -0
  10. package/dist/commands/worktree-settings.js +88 -0
  11. package/dist/commands/worktree.js +146 -50
  12. package/dist/database.js +85 -0
  13. package/dist/discord-bot.js +97 -55
  14. package/dist/discord-utils.js +51 -13
  15. package/dist/discord-utils.test.js +20 -0
  16. package/dist/escape-backticks.test.js +14 -3
  17. package/dist/interaction-handler.js +15 -0
  18. package/dist/session-handler.js +549 -412
  19. package/dist/system-message.js +25 -1
  20. package/dist/worktree-utils.js +50 -0
  21. package/package.json +1 -1
  22. package/src/__snapshots__/first-session-no-info.md +1344 -0
  23. package/src/__snapshots__/first-session-with-info.md +1350 -0
  24. package/src/__snapshots__/session-1.md +1344 -0
  25. package/src/__snapshots__/session-2.md +291 -0
  26. package/src/__snapshots__/session-3.md +20324 -0
  27. package/src/__snapshots__/session-with-tools.md +1344 -0
  28. package/src/channel-management.ts +6 -17
  29. package/src/cli.ts +250 -35
  30. package/src/commands/merge-worktree.ts +186 -0
  31. package/src/commands/permissions.ts +31 -5
  32. package/src/commands/queue.ts +5 -1
  33. package/src/commands/resume.ts +8 -18
  34. package/src/commands/session.ts +18 -44
  35. package/src/commands/user-command.ts +8 -19
  36. package/src/commands/verbosity.ts +71 -0
  37. package/src/commands/worktree-settings.ts +122 -0
  38. package/src/commands/worktree.ts +174 -55
  39. package/src/database.ts +108 -0
  40. package/src/discord-bot.ts +119 -63
  41. package/src/discord-utils.test.ts +23 -0
  42. package/src/discord-utils.ts +52 -13
  43. package/src/escape-backticks.test.ts +14 -3
  44. package/src/interaction-handler.ts +22 -0
  45. package/src/session-handler.ts +681 -436
  46. package/src/system-message.ts +37 -0
  47. package/src/worktree-utils.ts +78 -0
@@ -4,8 +4,7 @@
4
4
 
5
5
  import { ChannelType, type CategoryChannel, type Guild, type TextChannel } from 'discord.js'
6
6
  import path from 'node:path'
7
- import { getDatabase } from './database.js'
8
- import { extractTagsArrays } from './xml.js'
7
+ import { getDatabase, getChannelDirectory } from './database.js'
9
8
 
10
9
  export async function ensureKimakiCategory(
11
10
  guild: Guild,
@@ -83,7 +82,7 @@ export async function createProjectChannels({
83
82
  name: channelName,
84
83
  type: ChannelType.GuildText,
85
84
  parent: kimakiCategory,
86
- topic: `<kimaki><directory>${projectDirectory}</directory><app>${appId}</app></kimaki>`,
85
+ // Channel configuration is stored in SQLite, not in the topic
87
86
  })
88
87
 
89
88
  const voiceChannel = await guild.channels.create({
@@ -128,25 +127,15 @@ export async function getChannelsWithDescriptions(guild: Guild): Promise<Channel
128
127
  const textChannel = channel as TextChannel
129
128
  const description = textChannel.topic || null
130
129
 
131
- let kimakiDirectory: string | undefined
132
- let kimakiApp: string | undefined
133
-
134
- if (description) {
135
- const extracted = extractTagsArrays({
136
- xml: description,
137
- tags: ['kimaki.directory', 'kimaki.app'],
138
- })
139
-
140
- kimakiDirectory = extracted['kimaki.directory']?.[0]?.trim()
141
- kimakiApp = extracted['kimaki.app']?.[0]?.trim()
142
- }
130
+ // Get channel config from database instead of parsing XML from topic
131
+ const channelConfig = getChannelDirectory(textChannel.id)
143
132
 
144
133
  channels.push({
145
134
  id: textChannel.id,
146
135
  name: textChannel.name,
147
136
  description,
148
- kimakiDirectory,
149
- kimakiApp,
137
+ kimakiDirectory: channelConfig?.directory,
138
+ kimakiApp: channelConfig?.appId || undefined,
150
139
  })
151
140
  })
152
141
 
package/src/cli.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  getChannelsWithDescriptions,
22
22
  createDiscordClient,
23
23
  getDatabase,
24
+ getChannelDirectory,
24
25
  startDiscordBot,
25
26
  initializeOpencodeForDirectory,
26
27
  ensureKimakiCategory,
@@ -47,7 +48,6 @@ import { uploadFilesToDiscord } from './discord-utils.js'
47
48
  import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
48
49
  import http from 'node:http'
49
50
  import { setDataDir, getDataDir, getLockPort } from './config.js'
50
- import { extractTagsArrays } from './xml.js'
51
51
  import { sanitizeAgentName } from './commands/agent.js'
52
52
 
53
53
  const cliLogger = createLogger('CLI')
@@ -174,6 +174,7 @@ type CliOptions = {
174
174
  restart?: boolean
175
175
  addChannels?: boolean
176
176
  dataDir?: string
177
+ useWorktrees?: boolean
177
178
  }
178
179
 
179
180
  // Commands to skip when registering user commands (reserved names)
@@ -239,16 +240,28 @@ async function registerCommands({
239
240
  .toJSON(),
240
241
  new SlashCommandBuilder()
241
242
  .setName('new-worktree')
242
- .setDescription('Create a new git worktree and start a session thread')
243
+ .setDescription('Create a new git worktree (in thread: uses thread name if no name given)')
243
244
  .addStringOption((option) => {
244
245
  option
245
246
  .setName('name')
246
- .setDescription('Name for the worktree (will be formatted: lowercase, spaces to dashes)')
247
- .setRequired(true)
247
+ .setDescription('Name for worktree (optional in threads - uses thread name)')
248
+ .setRequired(false)
248
249
 
249
250
  return option
250
251
  })
251
252
  .toJSON(),
253
+ new SlashCommandBuilder()
254
+ .setName('merge-worktree')
255
+ .setDescription('Merge the worktree branch into the default branch')
256
+ .toJSON(),
257
+ new SlashCommandBuilder()
258
+ .setName('enable-worktrees')
259
+ .setDescription('Enable automatic git worktree creation for new sessions in this channel')
260
+ .toJSON(),
261
+ new SlashCommandBuilder()
262
+ .setName('disable-worktrees')
263
+ .setDescription('Disable automatic git worktree creation for new sessions in this channel')
264
+ .toJSON(),
252
265
  new SlashCommandBuilder()
253
266
  .setName('add-project')
254
267
  .setDescription('Create Discord channels for a new OpenCode project')
@@ -329,6 +342,21 @@ async function registerCommands({
329
342
  .setName('redo')
330
343
  .setDescription('Redo previously undone changes')
331
344
  .toJSON(),
345
+ new SlashCommandBuilder()
346
+ .setName('verbosity')
347
+ .setDescription('Set output verbosity for new sessions in this channel')
348
+ .addStringOption((option) => {
349
+ option
350
+ .setName('level')
351
+ .setDescription('Verbosity level')
352
+ .setRequired(true)
353
+ .addChoices(
354
+ { name: 'tools-and-text (default)', value: 'tools-and-text' },
355
+ { name: 'text-only', value: 'text-only' },
356
+ )
357
+ return option
358
+ })
359
+ .toJSON(),
332
360
  ]
333
361
 
334
362
  // Add user-defined commands with -cmd suffix
@@ -516,7 +544,7 @@ async function backgroundInit({
516
544
  }
517
545
  }
518
546
 
519
- async function run({ restart, addChannels }: CliOptions) {
547
+ async function run({ restart, addChannels, useWorktrees }: CliOptions) {
520
548
  const forceSetup = Boolean(restart)
521
549
 
522
550
  intro('🤖 Discord Bot Setup')
@@ -827,7 +855,7 @@ async function run({ restart, addChannels }: CliOptions) {
827
855
  const isQuickStart = existingBot && !forceSetup && !addChannels
828
856
  if (isQuickStart) {
829
857
  s.start('Starting Discord bot...')
830
- await startDiscordBot({ token, appId, discordClient })
858
+ await startDiscordBot({ token, appId, discordClient, useWorktrees })
831
859
  s.stop('Discord bot is running!')
832
860
 
833
861
  // Background: OpenCode init + slash command registration (non-blocking)
@@ -998,7 +1026,7 @@ async function run({ restart, addChannels }: CliOptions) {
998
1026
  })
999
1027
 
1000
1028
  s.start('Starting Discord bot...')
1001
- await startDiscordBot({ token, appId, discordClient })
1029
+ await startDiscordBot({ token, appId, discordClient, useWorktrees })
1002
1030
  s.stop('Discord bot is running!')
1003
1031
 
1004
1032
  showReadyMessage({ kimakiChannels, createdChannels, appId })
@@ -1011,12 +1039,14 @@ cli
1011
1039
  .option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
1012
1040
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
1013
1041
  .option('--install-url', 'Print the bot install URL and exit')
1042
+ .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
1014
1043
  .action(
1015
1044
  async (options: {
1016
1045
  restart?: boolean
1017
1046
  addChannels?: boolean
1018
1047
  dataDir?: string
1019
1048
  installUrl?: boolean
1049
+ useWorktrees?: boolean
1020
1050
  }) => {
1021
1051
  try {
1022
1052
  // Set data directory early, before any database access
@@ -1046,6 +1076,7 @@ cli
1046
1076
  restart: options.restart,
1047
1077
  addChannels: options.addChannels,
1048
1078
  dataDir: options.dataDir,
1079
+ useWorktrees: options.useWorktrees,
1049
1080
  })
1050
1081
  } catch (error) {
1051
1082
  cliLogger.error('Unhandled error:', error instanceof Error ? error.message : String(error))
@@ -1351,25 +1382,17 @@ cli
1351
1382
  guild_id: string
1352
1383
  }
1353
1384
 
1354
- if (!channelData.topic) {
1355
- s.stop('Channel has no topic')
1385
+ const channelConfig = getChannelDirectory(channelData.id)
1386
+
1387
+ if (!channelConfig) {
1388
+ s.stop('Channel not configured')
1356
1389
  throw new Error(
1357
- `Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`,
1390
+ `Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`,
1358
1391
  )
1359
1392
  }
1360
1393
 
1361
- const extracted = extractTagsArrays({
1362
- xml: channelData.topic,
1363
- tags: ['kimaki.directory', 'kimaki.app'],
1364
- })
1365
-
1366
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
1367
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
1368
-
1369
- if (!projectDirectory) {
1370
- s.stop('No kimaki.directory tag found')
1371
- throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`)
1372
- }
1394
+ const projectDirectory = channelConfig.directory
1395
+ const channelAppId = channelConfig.appId || undefined
1373
1396
 
1374
1397
  // Verify app ID matches if both are present
1375
1398
  if (channelAppId && appId && channelAppId !== appId) {
@@ -1386,6 +1409,13 @@ cli
1386
1409
  const DISCORD_MAX_LENGTH = 2000
1387
1410
  let starterMessage: { id: string }
1388
1411
 
1412
+ // Embed marker for auto-start sessions (unless --notify-only)
1413
+ // Bot checks for this embed footer to know it should start a session
1414
+ const AUTO_START_MARKER = 'kimaki:start'
1415
+ const autoStartEmbed = notifyOnly
1416
+ ? undefined
1417
+ : [{ color: 0x2b2d31, footer: { text: AUTO_START_MARKER } }]
1418
+
1389
1419
  if (prompt.length > DISCORD_MAX_LENGTH) {
1390
1420
  // Send as file attachment with a short summary
1391
1421
  const preview = prompt.slice(0, 100).replace(/\n/g, ' ')
@@ -1407,6 +1437,7 @@ cli
1407
1437
  JSON.stringify({
1408
1438
  content: summaryContent,
1409
1439
  attachments: [{ id: 0, filename: 'prompt.md' }],
1440
+ embeds: autoStartEmbed,
1410
1441
  }),
1411
1442
  )
1412
1443
  const buffer = fs.readFileSync(tmpFile)
@@ -1446,6 +1477,7 @@ cli
1446
1477
  },
1447
1478
  body: JSON.stringify({
1448
1479
  content: prompt,
1480
+ embeds: autoStartEmbed,
1449
1481
  }),
1450
1482
  },
1451
1483
  )
@@ -1486,19 +1518,6 @@ cli
1486
1518
 
1487
1519
  const threadData = (await threadResponse.json()) as { id: string; name: string }
1488
1520
 
1489
- // Mark thread for auto-start if not notify-only
1490
- // This is optional - only works if local database exists (for local bot auto-start)
1491
- if (!notifyOnly) {
1492
- try {
1493
- const db = getDatabase()
1494
- db.prepare('INSERT OR REPLACE INTO pending_auto_start (thread_id) VALUES (?)').run(
1495
- threadData.id,
1496
- )
1497
- } catch {
1498
- // Database not available (e.g., CI environment) - skip auto-start marking
1499
- }
1500
- }
1501
-
1502
1521
  s.stop('Thread created!')
1503
1522
 
1504
1523
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
@@ -1518,5 +1537,201 @@ cli
1518
1537
  }
1519
1538
  })
1520
1539
 
1540
+ cli
1541
+ .command('add-project [directory]', 'Create Discord channels for a project directory')
1542
+ .option('-g, --guild <guildId>', 'Discord guild/server ID (auto-detects if bot is in only one server)')
1543
+ .option('-a, --app-id <appId>', 'Bot application ID (reads from database if available)')
1544
+ .action(
1545
+ async (
1546
+ directory: string | undefined,
1547
+ options: {
1548
+ guild?: string
1549
+ appId?: string
1550
+ },
1551
+ ) => {
1552
+ try {
1553
+ const absolutePath = path.resolve(directory || '.')
1554
+
1555
+ if (!fs.existsSync(absolutePath)) {
1556
+ cliLogger.error(`Directory does not exist: ${absolutePath}`)
1557
+ process.exit(EXIT_NO_RESTART)
1558
+ }
1559
+
1560
+ // Get bot token from env var or database
1561
+ const envToken = process.env.KIMAKI_BOT_TOKEN
1562
+ let botToken: string | undefined
1563
+ let appId: string | undefined = options.appId
1564
+
1565
+ if (envToken) {
1566
+ botToken = envToken
1567
+ if (!appId) {
1568
+ try {
1569
+ const db = getDatabase()
1570
+ const botRow = db
1571
+ .prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
1572
+ .get() as { app_id: string } | undefined
1573
+ appId = botRow?.app_id
1574
+ } catch {
1575
+ // Database might not exist in CI
1576
+ }
1577
+ }
1578
+ } else {
1579
+ try {
1580
+ const db = getDatabase()
1581
+ const botRow = db
1582
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
1583
+ .get() as { app_id: string; token: string } | undefined
1584
+
1585
+ if (botRow) {
1586
+ botToken = botRow.token
1587
+ appId = appId || botRow.app_id
1588
+ }
1589
+ } catch (e) {
1590
+ cliLogger.error('Database error:', e instanceof Error ? e.message : String(e))
1591
+ }
1592
+ }
1593
+
1594
+ if (!botToken) {
1595
+ cliLogger.error(
1596
+ 'No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.',
1597
+ )
1598
+ process.exit(EXIT_NO_RESTART)
1599
+ }
1600
+
1601
+ if (!appId) {
1602
+ cliLogger.error(
1603
+ 'App ID is required to create channels. Use --app-id or run `kimaki` first.',
1604
+ )
1605
+ process.exit(EXIT_NO_RESTART)
1606
+ }
1607
+
1608
+ const s = spinner()
1609
+ s.start('Connecting to Discord...')
1610
+ const client = await createDiscordClient()
1611
+
1612
+ await new Promise<void>((resolve, reject) => {
1613
+ client.once(Events.ClientReady, () => {
1614
+ resolve()
1615
+ })
1616
+ client.once(Events.Error, reject)
1617
+ client.login(botToken)
1618
+ })
1619
+
1620
+ s.message('Finding guild...')
1621
+
1622
+ // Find guild
1623
+ let guild: Guild
1624
+ if (options.guild) {
1625
+ // Get raw guild ID from argv to avoid cac's number coercion losing precision on large IDs
1626
+ const guildArgIndex = process.argv.findIndex((arg) => arg === '-g' || arg === '--guild')
1627
+ const rawGuildArg = guildArgIndex >= 0 ? process.argv[guildArgIndex + 1] : undefined
1628
+ const guildId = rawGuildArg || String(options.guild)
1629
+ const foundGuild = client.guilds.cache.get(guildId)
1630
+ if (!foundGuild) {
1631
+ s.stop('Guild not found')
1632
+ cliLogger.error(`Guild not found: ${guildId}`)
1633
+ client.destroy()
1634
+ process.exit(EXIT_NO_RESTART)
1635
+ }
1636
+ guild = foundGuild
1637
+ } else {
1638
+ // Auto-detect: prefer guild with existing channels for this bot, else first guild
1639
+ const db = getDatabase()
1640
+ const existingChannelRow = db
1641
+ .prepare(
1642
+ 'SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1',
1643
+ )
1644
+ .get(appId) as { channel_id: string } | undefined
1645
+
1646
+ if (existingChannelRow) {
1647
+ try {
1648
+ const ch = await client.channels.fetch(existingChannelRow.channel_id)
1649
+ if (ch && 'guild' in ch && ch.guild) {
1650
+ guild = ch.guild
1651
+ } else {
1652
+ throw new Error('Channel has no guild')
1653
+ }
1654
+ } catch {
1655
+ // Channel might be deleted, fall back to first guild
1656
+ const firstGuild = client.guilds.cache.first()
1657
+ if (!firstGuild) {
1658
+ s.stop('No guild found')
1659
+ cliLogger.error('No guild found. Add the bot to a server first.')
1660
+ client.destroy()
1661
+ process.exit(EXIT_NO_RESTART)
1662
+ }
1663
+ guild = firstGuild
1664
+ }
1665
+ } else {
1666
+ const firstGuild = client.guilds.cache.first()
1667
+ if (!firstGuild) {
1668
+ s.stop('No guild found')
1669
+ cliLogger.error('No guild found. Add the bot to a server first.')
1670
+ client.destroy()
1671
+ process.exit(EXIT_NO_RESTART)
1672
+ }
1673
+ guild = firstGuild
1674
+ }
1675
+ }
1676
+
1677
+ // Check if channel already exists in this guild
1678
+ s.message('Checking for existing channel...')
1679
+ try {
1680
+ const db = getDatabase()
1681
+ const existingChannels = db
1682
+ .prepare(
1683
+ 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?',
1684
+ )
1685
+ .all(absolutePath, 'text', appId) as { channel_id: string }[]
1686
+
1687
+ for (const existingChannel of existingChannels) {
1688
+ try {
1689
+ const ch = await client.channels.fetch(existingChannel.channel_id)
1690
+ if (ch && 'guild' in ch && ch.guild?.id === guild.id) {
1691
+ s.stop('Channel already exists')
1692
+ note(
1693
+ `Channel already exists for this directory in ${guild.name}.\n\nChannel: <#${existingChannel.channel_id}>\nDirectory: ${absolutePath}`,
1694
+ '⚠️ Already Exists',
1695
+ )
1696
+ client.destroy()
1697
+ process.exit(0)
1698
+ }
1699
+ } catch {
1700
+ // Channel might be deleted, continue checking
1701
+ }
1702
+ }
1703
+ } catch {
1704
+ // Database might not exist, continue to create
1705
+ }
1706
+
1707
+ s.message(`Creating channels in ${guild.name}...`)
1708
+
1709
+ const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
1710
+ guild,
1711
+ projectDirectory: absolutePath,
1712
+ appId,
1713
+ botName: client.user?.username,
1714
+ })
1715
+
1716
+ client.destroy()
1717
+
1718
+ s.stop('Channels created!')
1719
+
1720
+ const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`
1721
+
1722
+ note(
1723
+ `Created channels for project:\n\n📝 Text: #${channelName}\n🔊 Voice: #${channelName}\n📁 Directory: ${absolutePath}\n\nURL: ${channelUrl}`,
1724
+ '✅ Success',
1725
+ )
1726
+
1727
+ console.log(channelUrl)
1728
+ process.exit(0)
1729
+ } catch (error) {
1730
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
1731
+ process.exit(EXIT_NO_RESTART)
1732
+ }
1733
+ },
1734
+ )
1735
+
1521
1736
  cli.help()
1522
1737
  cli.parse()
@@ -0,0 +1,186 @@
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
+
5
+ import { type ThreadChannel } from 'discord.js'
6
+ import type { CommandContext } from './types.js'
7
+ import { getThreadWorktree } from '../database.js'
8
+ import { createLogger } from '../logger.js'
9
+ import { execAsync } from '../worktree-utils.js'
10
+
11
+ const logger = createLogger('MERGE-WORKTREE')
12
+
13
+ /** Worktree thread title prefix - indicates unmerged worktree */
14
+ export const WORKTREE_PREFIX = '⬦ '
15
+
16
+ /**
17
+ * Remove the worktree prefix from a thread title.
18
+ * Uses Promise.race with timeout since Discord thread title updates can hang.
19
+ */
20
+ async function removeWorktreePrefixFromTitle(thread: ThreadChannel): Promise<void> {
21
+ if (!thread.name.startsWith(WORKTREE_PREFIX)) {
22
+ return
23
+ }
24
+
25
+ const newName = thread.name.slice(WORKTREE_PREFIX.length)
26
+
27
+ // Race between the edit and a timeout - thread title updates are heavily rate-limited
28
+ const timeoutMs = 5000
29
+ const editPromise = thread.setName(newName).catch((e) => {
30
+ logger.warn(`Failed to update thread title: ${e instanceof Error ? e.message : String(e)}`)
31
+ })
32
+
33
+ const timeoutPromise = new Promise<void>((resolve) => {
34
+ setTimeout(() => {
35
+ logger.warn(`Thread title update timed out after ${timeoutMs}ms`)
36
+ resolve()
37
+ }, timeoutMs)
38
+ })
39
+
40
+ await Promise.race([editPromise, timeoutPromise])
41
+ }
42
+
43
+ /**
44
+ * Check if worktree is in detached HEAD state.
45
+ */
46
+ async function isDetachedHead(worktreeDir: string): Promise<boolean> {
47
+ try {
48
+ await execAsync(`git -C "${worktreeDir}" symbolic-ref HEAD`)
49
+ return false
50
+ } catch {
51
+ return true
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Get current branch name (returns null if detached).
57
+ */
58
+ async function getCurrentBranch(worktreeDir: string): Promise<string | null> {
59
+ try {
60
+ const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`)
61
+ return stdout.trim() || null
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ export async function handleMergeWorktreeCommand({ command, appId }: CommandContext): Promise<void> {
68
+ await command.deferReply({ ephemeral: false })
69
+
70
+ const channel = command.channel
71
+
72
+ // Must be in a thread
73
+ if (!channel || !channel.isThread()) {
74
+ await command.editReply('This command can only be used in a thread')
75
+ return
76
+ }
77
+
78
+ const thread = channel as ThreadChannel
79
+
80
+ // Get worktree info from database
81
+ const worktreeInfo = getThreadWorktree(thread.id)
82
+ if (!worktreeInfo) {
83
+ await command.editReply('This thread is not associated with a worktree')
84
+ return
85
+ }
86
+
87
+ if (worktreeInfo.status !== 'ready' || !worktreeInfo.worktree_directory) {
88
+ await command.editReply(
89
+ `Worktree is not ready (status: ${worktreeInfo.status})${worktreeInfo.error_message ? `: ${worktreeInfo.error_message}` : ''}`,
90
+ )
91
+ return
92
+ }
93
+
94
+ const mainRepoDir = worktreeInfo.project_directory
95
+ const worktreeDir = worktreeInfo.worktree_directory
96
+
97
+ try {
98
+ // 1. Check for uncommitted changes
99
+ const { stdout: status } = await execAsync(`git -C "${worktreeDir}" status --porcelain`)
100
+ if (status.trim()) {
101
+ await command.editReply(
102
+ `❌ Uncommitted changes detected in worktree.\n\nPlease commit your changes first, then retry \`/merge-worktree\`.`,
103
+ )
104
+ return
105
+ }
106
+
107
+ // 2. Get the default branch name
108
+ logger.log(`Getting default branch for ${mainRepoDir}`)
109
+ let defaultBranch: string
110
+
111
+ try {
112
+ const { stdout } = await execAsync(
113
+ `git -C "${mainRepoDir}" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@'`,
114
+ )
115
+ defaultBranch = stdout.trim() || 'main'
116
+ } catch {
117
+ defaultBranch = 'main'
118
+ }
119
+
120
+ // 3. Determine if we're on a branch or detached HEAD
121
+ const isDetached = await isDetachedHead(worktreeDir)
122
+ const currentBranch = await getCurrentBranch(worktreeDir)
123
+ let branchToMerge: string
124
+ let tempBranch: string | null = null
125
+
126
+ if (isDetached) {
127
+ // Create a temporary branch from detached HEAD
128
+ tempBranch = `temp-merge-${Date.now()}`
129
+ logger.log(`Detached HEAD detected, creating temp branch: ${tempBranch}`)
130
+ await execAsync(`git -C "${worktreeDir}" checkout -b ${tempBranch}`)
131
+ branchToMerge = tempBranch
132
+ } else {
133
+ branchToMerge = currentBranch || worktreeInfo.worktree_name
134
+ }
135
+
136
+ logger.log(`Default branch: ${defaultBranch}, branch to merge: ${branchToMerge}`)
137
+
138
+ // 4. Merge default branch INTO worktree (handles diverged branches)
139
+ logger.log(`Merging ${defaultBranch} into worktree at ${worktreeDir}`)
140
+ try {
141
+ await execAsync(`git -C "${worktreeDir}" merge ${defaultBranch} --no-edit`)
142
+ } catch (e) {
143
+ // If merge fails (conflicts), abort and report
144
+ await execAsync(`git -C "${worktreeDir}" merge --abort`).catch(() => {})
145
+ // Clean up temp branch if we created one
146
+ if (tempBranch) {
147
+ await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch(() => {})
148
+ await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch(() => {})
149
+ }
150
+ throw new Error(`Merge conflict - resolve manually in worktree then retry`)
151
+ }
152
+
153
+ // 5. Update default branch ref to point to current HEAD
154
+ // Use update-ref instead of fetch because fetch refuses if branch is checked out
155
+ logger.log(`Updating ${defaultBranch} to point to current HEAD`)
156
+ const { stdout: commitHash } = await execAsync(`git -C "${worktreeDir}" rev-parse HEAD`)
157
+ await execAsync(`git -C "${mainRepoDir}" update-ref refs/heads/${defaultBranch} ${commitHash.trim()}`)
158
+
159
+ // 6. Switch to detached HEAD at default branch (allows main to be checked out elsewhere)
160
+ logger.log(`Switching to detached HEAD at ${defaultBranch}`)
161
+ await execAsync(`git -C "${worktreeDir}" checkout --detach ${defaultBranch}`)
162
+
163
+ // 7. Delete the merged branch (temp or original)
164
+ logger.log(`Deleting merged branch ${branchToMerge}`)
165
+ await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch(() => {})
166
+
167
+ // Also delete the original worktree branch if different from what we merged
168
+ if (!isDetached && branchToMerge !== worktreeInfo.worktree_name) {
169
+ await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch(() => {})
170
+ }
171
+
172
+ // 8. Remove worktree prefix from thread title (fire and forget with timeout)
173
+ void removeWorktreePrefixFromTitle(thread)
174
+
175
+ const sourceDesc = isDetached ? 'detached commits' : `\`${branchToMerge}\``
176
+ await command.editReply(
177
+ `✅ Merged ${sourceDesc} into \`${defaultBranch}\`\n\nWorktree now at detached HEAD - you can keep working here.`,
178
+ )
179
+
180
+ logger.log(`Successfully merged ${branchToMerge} into ${defaultBranch}`)
181
+ } catch (e) {
182
+ const errorMsg = e instanceof Error ? e.message : String(e)
183
+ logger.error(`Merge failed: ${errorMsg}`)
184
+ await command.editReply(`❌ Merge failed:\n\`\`\`\n${errorMsg}\n\`\`\``)
185
+ }
186
+ }