kimaki 0.4.43 → 0.4.44

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.
@@ -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
+ }
@@ -0,0 +1,122 @@
1
+ // /enable-worktrees and /disable-worktrees commands.
2
+ // Allows per-channel opt-in for automatic worktree creation,
3
+ // as an alternative to the global --use-worktrees CLI flag.
4
+
5
+ import { ChatInputCommandInteraction, ChannelType, type TextChannel } from 'discord.js'
6
+ import { getChannelWorktreesEnabled, setChannelWorktreesEnabled } from '../database.js'
7
+ import { getKimakiMetadata } from '../discord-utils.js'
8
+ import { createLogger } from '../logger.js'
9
+
10
+ const worktreeSettingsLogger = createLogger('WORKTREE_SETTINGS')
11
+
12
+ /**
13
+ * Handle the /enable-worktrees slash command.
14
+ * Enables automatic worktree creation for new sessions in this channel.
15
+ */
16
+ export async function handleEnableWorktreesCommand({
17
+ command,
18
+ appId,
19
+ }: {
20
+ command: ChatInputCommandInteraction
21
+ appId: string
22
+ }): Promise<void> {
23
+ worktreeSettingsLogger.log('[ENABLE_WORKTREES] Command called')
24
+
25
+ const channel = command.channel
26
+
27
+ if (!channel || channel.type !== ChannelType.GuildText) {
28
+ await command.reply({
29
+ content: 'This command can only be used in text channels (not threads).',
30
+ ephemeral: true,
31
+ })
32
+ return
33
+ }
34
+
35
+ const textChannel = channel as TextChannel
36
+ const metadata = getKimakiMetadata(textChannel)
37
+
38
+ if (metadata.channelAppId && metadata.channelAppId !== appId) {
39
+ await command.reply({
40
+ content: 'This channel is configured for a different bot.',
41
+ ephemeral: true,
42
+ })
43
+ return
44
+ }
45
+
46
+ if (!metadata.projectDirectory) {
47
+ await command.reply({
48
+ content:
49
+ 'This channel is not configured with a project directory.\nAdd a `<kimaki.directory>/path/to/project</kimaki.directory>` tag to the channel description.',
50
+ ephemeral: true,
51
+ })
52
+ return
53
+ }
54
+
55
+ const wasEnabled = getChannelWorktreesEnabled(textChannel.id)
56
+ setChannelWorktreesEnabled(textChannel.id, true)
57
+
58
+ worktreeSettingsLogger.log(`[ENABLE_WORKTREES] Enabled for channel ${textChannel.id}`)
59
+
60
+ await command.reply({
61
+ content: wasEnabled
62
+ ? `Worktrees are already enabled for this channel.\n\nNew sessions started from messages in **#${textChannel.name}** will automatically create git worktrees.`
63
+ : `Worktrees **enabled** for this channel.\n\nNew sessions started from messages in **#${textChannel.name}** will now automatically create git worktrees.\n\nUse \`/disable-worktrees\` to turn this off.`,
64
+ ephemeral: true,
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Handle the /disable-worktrees slash command.
70
+ * Disables automatic worktree creation for new sessions in this channel.
71
+ */
72
+ export async function handleDisableWorktreesCommand({
73
+ command,
74
+ appId,
75
+ }: {
76
+ command: ChatInputCommandInteraction
77
+ appId: string
78
+ }): Promise<void> {
79
+ worktreeSettingsLogger.log('[DISABLE_WORKTREES] Command called')
80
+
81
+ const channel = command.channel
82
+
83
+ if (!channel || channel.type !== ChannelType.GuildText) {
84
+ await command.reply({
85
+ content: 'This command can only be used in text channels (not threads).',
86
+ ephemeral: true,
87
+ })
88
+ return
89
+ }
90
+
91
+ const textChannel = channel as TextChannel
92
+ const metadata = getKimakiMetadata(textChannel)
93
+
94
+ if (metadata.channelAppId && metadata.channelAppId !== appId) {
95
+ await command.reply({
96
+ content: 'This channel is configured for a different bot.',
97
+ ephemeral: true,
98
+ })
99
+ return
100
+ }
101
+
102
+ if (!metadata.projectDirectory) {
103
+ await command.reply({
104
+ content:
105
+ 'This channel is not configured with a project directory.\nAdd a `<kimaki.directory>/path/to/project</kimaki.directory>` tag to the channel description.',
106
+ ephemeral: true,
107
+ })
108
+ return
109
+ }
110
+
111
+ const wasEnabled = getChannelWorktreesEnabled(textChannel.id)
112
+ setChannelWorktreesEnabled(textChannel.id, false)
113
+
114
+ worktreeSettingsLogger.log(`[DISABLE_WORKTREES] Disabled for channel ${textChannel.id}`)
115
+
116
+ await command.reply({
117
+ content: wasEnabled
118
+ ? `Worktrees **disabled** for this channel.\n\nNew sessions started from messages in **#${textChannel.name}** will use the main project directory.\n\nUse \`/enable-worktrees\` to turn this back on.`
119
+ : `Worktrees are already disabled for this channel.\n\nNew sessions will use the main project directory.`,
120
+ ephemeral: true,
121
+ })
122
+ }
@@ -14,6 +14,8 @@ import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode
14
14
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
15
15
  import { extractTagsArrays } from '../xml.js'
16
16
  import { createLogger } from '../logger.js'
17
+ import { createWorktreeWithSubmodules } from '../worktree-utils.js'
18
+ import { WORKTREE_PREFIX } from './merge-worktree.js'
17
19
  import * as errore from 'errore'
18
20
 
19
21
  const logger = createLogger('WORKTREE')
@@ -26,17 +28,17 @@ class WorktreeError extends Error {
26
28
  }
27
29
 
28
30
  /**
29
- * Format worktree name: lowercase, spaces to dashes, remove special chars, add kimaki- prefix.
30
- * "My Feature" → "kimaki-my-feature"
31
+ * Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/kimaki- prefix.
32
+ * "My Feature" → "opencode/kimaki-my-feature"
31
33
  */
32
- function formatWorktreeName(name: string): string {
34
+ export function formatWorktreeName(name: string): string {
33
35
  const formatted = name
34
36
  .toLowerCase()
35
37
  .trim()
36
38
  .replace(/\s+/g, '-')
37
39
  .replace(/[^a-z0-9-]/g, '')
38
40
 
39
- return `kimaki-${formatted}`
41
+ return `opencode/kimaki-${formatted}`
40
42
  }
41
43
 
42
44
  /**
@@ -89,33 +91,17 @@ async function createWorktreeInBackground({
89
91
  projectDirectory: string
90
92
  clientV2: ReturnType<typeof getOpencodeClientV2> & {}
91
93
  }): Promise<void> {
92
- // Create worktree using SDK v2
94
+ // Create worktree using SDK v2 and init submodules
93
95
  logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory}`)
94
- const worktreeResult = await errore.tryAsync({
95
- try: async () => {
96
- const response = await clientV2.worktree.create({
97
- directory: projectDirectory,
98
- worktreeCreateInput: {
99
- name: worktreeName,
100
- },
101
- })
102
-
103
- if (response.error) {
104
- throw new Error(`SDK error: ${JSON.stringify(response.error)}`)
105
- }
106
-
107
- if (!response.data) {
108
- throw new Error('No worktree data returned from SDK')
109
- }
110
-
111
- return response.data
112
- },
113
- catch: (e) => new WorktreeError('Failed to create worktree', { cause: e }),
96
+ const worktreeResult = await createWorktreeWithSubmodules({
97
+ clientV2,
98
+ directory: projectDirectory,
99
+ name: worktreeName,
114
100
  })
115
101
 
116
- if (errore.isError(worktreeResult)) {
102
+ if (worktreeResult instanceof Error) {
117
103
  const errorMsg = worktreeResult.message
118
- logger.error('[NEW-WORKTREE] Error:', worktreeResult.cause)
104
+ logger.error('[NEW-WORKTREE] Error:', worktreeResult)
119
105
  setWorktreeError({ threadId: thread.id, errorMessage: errorMsg })
120
106
  await starterMessage.edit(`🌳 **Worktree: ${worktreeName}**\n❌ ${errorMsg}`)
121
107
  return
@@ -203,7 +189,7 @@ export async function handleNewWorktreeCommand({
203
189
  })
204
190
 
205
191
  const thread = await starterMessage.startThread({
206
- name: `worktree: ${worktreeName}`,
192
+ name: `${WORKTREE_PREFIX}worktree: ${worktreeName}`,
207
193
  autoArchiveDuration: 1440,
208
194
  reason: 'Worktree session',
209
195
  })
package/src/database.ts CHANGED
@@ -105,6 +105,7 @@ export function getDatabase(): Database.Database {
105
105
  `)
106
106
 
107
107
  runModelMigrations(db)
108
+ runWorktreeSettingsMigrations(db)
108
109
  }
109
110
 
110
111
  return db
@@ -338,6 +339,48 @@ export function deleteThreadWorktree(threadId: string): void {
338
339
  db.prepare('DELETE FROM thread_worktrees WHERE thread_id = ?').run(threadId)
339
340
  }
340
341
 
342
+ /**
343
+ * Run migrations for channel worktree settings table.
344
+ * Called on startup. Allows per-channel opt-in for automatic worktree creation.
345
+ */
346
+ export function runWorktreeSettingsMigrations(database?: Database.Database): void {
347
+ const targetDb = database || getDatabase()
348
+
349
+ targetDb.exec(`
350
+ CREATE TABLE IF NOT EXISTS channel_worktrees (
351
+ channel_id TEXT PRIMARY KEY,
352
+ enabled INTEGER NOT NULL DEFAULT 0,
353
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
354
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
355
+ )
356
+ `)
357
+
358
+ dbLogger.log('Channel worktree settings migrations complete')
359
+ }
360
+
361
+ /**
362
+ * Check if automatic worktree creation is enabled for a channel.
363
+ */
364
+ export function getChannelWorktreesEnabled(channelId: string): boolean {
365
+ const db = getDatabase()
366
+ const row = db
367
+ .prepare('SELECT enabled FROM channel_worktrees WHERE channel_id = ?')
368
+ .get(channelId) as { enabled: number } | undefined
369
+ return row?.enabled === 1
370
+ }
371
+
372
+ /**
373
+ * Enable or disable automatic worktree creation for a channel.
374
+ */
375
+ export function setChannelWorktreesEnabled(channelId: string, enabled: boolean): void {
376
+ const db = getDatabase()
377
+ db.prepare(
378
+ `INSERT INTO channel_worktrees (channel_id, enabled, updated_at)
379
+ VALUES (?, ?, CURRENT_TIMESTAMP)
380
+ ON CONFLICT(channel_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`,
381
+ ).run(channelId, enabled ? 1 : 0, enabled ? 1 : 0)
382
+ }
383
+
341
384
  export function closeDatabase(): void {
342
385
  if (db) {
343
386
  db.close()
@@ -2,8 +2,19 @@
2
2
  // Bridges Discord messages to OpenCode sessions, manages voice connections,
3
3
  // and orchestrates the main event loop for the Kimaki bot.
4
4
 
5
- import { getDatabase, closeDatabase, getThreadWorktree } from './database.js'
6
- import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js'
5
+ import {
6
+ getDatabase,
7
+ closeDatabase,
8
+ getThreadWorktree,
9
+ createPendingWorktree,
10
+ setWorktreeReady,
11
+ setWorktreeError,
12
+ getChannelWorktreesEnabled,
13
+ } from './database.js'
14
+ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js'
15
+ import { formatWorktreeName } from './commands/worktree.js'
16
+ import { WORKTREE_PREFIX } from './commands/merge-worktree.js'
17
+ import { createWorktreeWithSubmodules } from './worktree-utils.js'
7
18
  import {
8
19
  escapeBackticksInCodeBlocks,
9
20
  splitMarkdownForDiscord,
@@ -69,6 +80,8 @@ const voiceLogger = createLogger('VOICE')
69
80
  type StartOptions = {
70
81
  token: string
71
82
  appId?: string
83
+ /** When true, all new sessions from channel messages create git worktrees */
84
+ useWorktrees?: boolean
72
85
  }
73
86
 
74
87
  export async function createDiscordClient() {
@@ -87,6 +100,7 @@ export async function startDiscordBot({
87
100
  token,
88
101
  appId,
89
102
  discordClient,
103
+ useWorktrees,
90
104
  }: StartOptions & { discordClient?: Client }) {
91
105
  if (!discordClient) {
92
106
  discordClient = await createDiscordClient()
@@ -401,10 +415,18 @@ export async function startDiscordBot({
401
415
 
402
416
  const hasVoice = message.attachments.some((a) => a.contentType?.startsWith('audio/'))
403
417
 
404
- const threadName = hasVoice
418
+ const baseThreadName = hasVoice
405
419
  ? 'Voice Message'
406
420
  : message.content?.replace(/\s+/g, ' ').trim() || 'Claude Thread'
407
421
 
422
+ // Check if worktrees should be enabled (CLI flag OR channel setting)
423
+ const shouldUseWorktrees = useWorktrees || getChannelWorktreesEnabled(textChannel.id)
424
+
425
+ // Add worktree prefix if worktrees are enabled
426
+ const threadName = shouldUseWorktrees
427
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
428
+ : baseThreadName
429
+
408
430
  const thread = await message.startThread({
409
431
  name: threadName.slice(0, 80),
410
432
  autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
@@ -413,12 +435,65 @@ export async function startDiscordBot({
413
435
 
414
436
  discordLogger.log(`Created thread "${thread.name}" (${thread.id})`)
415
437
 
438
+ // Create worktree if worktrees are enabled (CLI flag OR channel setting)
439
+ let sessionDirectory = projectDirectory
440
+ if (shouldUseWorktrees) {
441
+ const worktreeName = formatWorktreeName(
442
+ hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50),
443
+ )
444
+ discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`)
445
+
446
+ // Store pending worktree immediately so bot knows about it
447
+ createPendingWorktree({
448
+ threadId: thread.id,
449
+ worktreeName,
450
+ projectDirectory,
451
+ })
452
+
453
+ // Initialize OpenCode and create worktree
454
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
455
+ if (getClient instanceof Error) {
456
+ discordLogger.error(`[WORKTREE] Failed to init OpenCode: ${getClient.message}`)
457
+ setWorktreeError({ threadId: thread.id, errorMessage: getClient.message })
458
+ await thread.send({
459
+ content: `⚠️ Failed to create worktree: ${getClient.message}\nUsing main project directory instead.`,
460
+ flags: SILENT_MESSAGE_FLAGS,
461
+ })
462
+ } else {
463
+ const clientV2 = getOpencodeClientV2(projectDirectory)
464
+ if (!clientV2) {
465
+ discordLogger.error(`[WORKTREE] No v2 client for ${projectDirectory}`)
466
+ setWorktreeError({ threadId: thread.id, errorMessage: 'No OpenCode v2 client' })
467
+ } else {
468
+ const worktreeResult = await createWorktreeWithSubmodules({
469
+ clientV2,
470
+ directory: projectDirectory,
471
+ name: worktreeName,
472
+ })
473
+
474
+ if (worktreeResult instanceof Error) {
475
+ const errMsg = worktreeResult.message
476
+ discordLogger.error(`[WORKTREE] Creation failed: ${errMsg}`)
477
+ setWorktreeError({ threadId: thread.id, errorMessage: errMsg })
478
+ await thread.send({
479
+ content: `⚠️ Failed to create worktree: ${errMsg}\nUsing main project directory instead.`,
480
+ flags: SILENT_MESSAGE_FLAGS,
481
+ })
482
+ } else {
483
+ setWorktreeReady({ threadId: thread.id, worktreeDirectory: worktreeResult.directory })
484
+ sessionDirectory = worktreeResult.directory
485
+ discordLogger.log(`[WORKTREE] Created: ${worktreeResult.directory} (branch: ${worktreeResult.branch})`)
486
+ }
487
+ }
488
+ }
489
+ }
490
+
416
491
  let messageContent = message.content || ''
417
492
 
418
493
  const transcription = await processVoiceAttachment({
419
494
  message,
420
495
  thread,
421
- projectDirectory,
496
+ projectDirectory: sessionDirectory,
422
497
  isNewThread: true,
423
498
  appId: currentAppId,
424
499
  })
@@ -434,7 +509,7 @@ export async function startDiscordBot({
434
509
  await handleOpencodeSession({
435
510
  prompt: promptWithAttachments,
436
511
  thread,
437
- projectDirectory,
512
+ projectDirectory: sessionDirectory,
438
513
  originalMessage: message,
439
514
  images: fileAttachments,
440
515
  channelId: textChannel.id,
@@ -454,40 +529,37 @@ export async function startDiscordBot({
454
529
  })
455
530
 
456
531
  // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
532
+ // Uses embed marker instead of database to avoid race conditions
533
+ const AUTO_START_MARKER = 'kimaki:start'
457
534
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
458
535
  try {
459
536
  if (!newlyCreated) {
460
537
  return
461
538
  }
462
539
 
463
- // Check if this thread is marked for auto-start in the database
464
- const db = getDatabase()
465
- const pendingRow = db
466
- .prepare('SELECT thread_id FROM pending_auto_start WHERE thread_id = ?')
467
- .get(thread.id) as { thread_id: string } | undefined
468
-
469
- if (!pendingRow) {
470
- return // Not a CLI-initiated auto-start thread
471
- }
472
-
473
- // Remove from pending table
474
- db.prepare('DELETE FROM pending_auto_start WHERE thread_id = ?').run(thread.id)
475
-
476
- discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
477
-
478
540
  // Only handle threads in text channels
479
541
  const parent = thread.parent as TextChannel | null
480
542
  if (!parent || parent.type !== ChannelType.GuildText) {
481
543
  return
482
544
  }
483
545
 
484
- // Get the starter message for the prompt
546
+ // Get the starter message to check for auto-start marker
485
547
  const starterMessage = await thread.fetchStarterMessage().catch(() => null)
486
548
  if (!starterMessage) {
487
549
  discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
488
550
  return
489
551
  }
490
552
 
553
+ // Check if starter message has the auto-start embed marker
554
+ const hasAutoStartMarker = starterMessage.embeds.some(
555
+ (embed) => embed.footer?.text === AUTO_START_MARKER,
556
+ )
557
+ if (!hasAutoStartMarker) {
558
+ return // Not a CLI-initiated auto-start thread
559
+ }
560
+
561
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
562
+
491
563
  const prompt = starterMessage.content.trim()
492
564
  if (!prompt) {
493
565
  discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
@@ -5,6 +5,11 @@
5
5
  import { Events, type Client, type Interaction } from 'discord.js'
6
6
  import { handleSessionCommand, handleSessionAutocomplete } from './commands/session.js'
7
7
  import { handleNewWorktreeCommand } from './commands/worktree.js'
8
+ import { handleMergeWorktreeCommand } from './commands/merge-worktree.js'
9
+ import {
10
+ handleEnableWorktreesCommand,
11
+ handleDisableWorktreesCommand,
12
+ } from './commands/worktree-settings.js'
8
13
  import { handleResumeCommand, handleResumeAutocomplete } from './commands/resume.js'
9
14
  import { handleAddProjectCommand, handleAddProjectAutocomplete } from './commands/add-project.js'
10
15
  import {
@@ -87,6 +92,18 @@ export function registerInteractionHandler({
87
92
  await handleNewWorktreeCommand({ command: interaction, appId })
88
93
  return
89
94
 
95
+ case 'merge-worktree':
96
+ await handleMergeWorktreeCommand({ command: interaction, appId })
97
+ return
98
+
99
+ case 'enable-worktrees':
100
+ await handleEnableWorktreesCommand({ command: interaction, appId })
101
+ return
102
+
103
+ case 'disable-worktrees':
104
+ await handleDisableWorktreesCommand({ command: interaction, appId })
105
+ return
106
+
90
107
  case 'resume':
91
108
  await handleResumeCommand({ command: interaction, appId })
92
109
  return