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.
- package/dist/cli.js +175 -14
- package/dist/commands/merge-worktree.js +152 -0
- package/dist/commands/worktree-settings.js +88 -0
- package/dist/commands/worktree.js +14 -25
- package/dist/database.js +36 -0
- package/dist/discord-bot.js +74 -18
- package/dist/interaction-handler.js +11 -0
- package/dist/session-handler.js +11 -2
- package/dist/system-message.js +25 -1
- package/dist/worktree-utils.js +50 -0
- package/package.json +1 -1
- package/src/cli.ts +213 -16
- package/src/commands/merge-worktree.ts +186 -0
- package/src/commands/worktree-settings.ts +122 -0
- package/src/commands/worktree.ts +14 -28
- package/src/database.ts +43 -0
- package/src/discord-bot.ts +93 -21
- package/src/interaction-handler.ts +17 -0
- package/src/session-handler.ts +14 -2
- package/src/system-message.ts +37 -0
- package/src/worktree-utils.ts +78 -0
|
@@ -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
|
+
}
|
package/src/commands/worktree.ts
CHANGED
|
@@ -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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 (
|
|
102
|
+
if (worktreeResult instanceof Error) {
|
|
117
103
|
const errorMsg = worktreeResult.message
|
|
118
|
-
logger.error('[NEW-WORKTREE] Error:', worktreeResult
|
|
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:
|
|
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()
|
package/src/discord-bot.ts
CHANGED
|
@@ -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 {
|
|
6
|
-
|
|
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
|
|
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
|
|
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
|