kimaki 0.4.38 → 0.4.40

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 (55) hide show
  1. package/dist/cli.js +27 -23
  2. package/dist/commands/abort.js +15 -6
  3. package/dist/commands/add-project.js +9 -0
  4. package/dist/commands/agent.js +13 -1
  5. package/dist/commands/fork.js +13 -2
  6. package/dist/commands/model.js +12 -0
  7. package/dist/commands/remove-project.js +26 -16
  8. package/dist/commands/resume.js +9 -0
  9. package/dist/commands/session.js +14 -1
  10. package/dist/commands/share.js +10 -1
  11. package/dist/commands/undo-redo.js +13 -4
  12. package/dist/commands/worktree.js +180 -0
  13. package/dist/database.js +57 -5
  14. package/dist/discord-bot.js +48 -10
  15. package/dist/discord-utils.js +36 -0
  16. package/dist/errors.js +109 -0
  17. package/dist/genai-worker.js +18 -16
  18. package/dist/interaction-handler.js +6 -2
  19. package/dist/markdown.js +100 -85
  20. package/dist/markdown.test.js +10 -3
  21. package/dist/message-formatting.js +50 -37
  22. package/dist/opencode.js +43 -46
  23. package/dist/session-handler.js +100 -2
  24. package/dist/system-message.js +2 -0
  25. package/dist/tools.js +18 -8
  26. package/dist/voice-handler.js +48 -25
  27. package/dist/voice.js +159 -131
  28. package/package.json +4 -2
  29. package/src/cli.ts +31 -32
  30. package/src/commands/abort.ts +17 -7
  31. package/src/commands/add-project.ts +9 -0
  32. package/src/commands/agent.ts +13 -1
  33. package/src/commands/fork.ts +18 -7
  34. package/src/commands/model.ts +12 -0
  35. package/src/commands/remove-project.ts +28 -16
  36. package/src/commands/resume.ts +9 -0
  37. package/src/commands/session.ts +14 -1
  38. package/src/commands/share.ts +11 -1
  39. package/src/commands/undo-redo.ts +15 -6
  40. package/src/commands/worktree.ts +243 -0
  41. package/src/database.ts +104 -4
  42. package/src/discord-bot.ts +49 -9
  43. package/src/discord-utils.ts +50 -0
  44. package/src/errors.ts +138 -0
  45. package/src/genai-worker.ts +20 -17
  46. package/src/interaction-handler.ts +7 -2
  47. package/src/markdown.test.ts +13 -3
  48. package/src/markdown.ts +112 -95
  49. package/src/message-formatting.ts +55 -38
  50. package/src/opencode.ts +52 -49
  51. package/src/session-handler.ts +118 -3
  52. package/src/system-message.ts +2 -0
  53. package/src/tools.ts +18 -8
  54. package/src/voice-handler.ts +48 -23
  55. package/src/voice.ts +195 -148
@@ -14,6 +14,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
14
14
  import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js'
15
15
  import { collectLastAssistantParts } from '../message-formatting.js'
16
16
  import { createLogger } from '../logger.js'
17
+ import * as errore from 'errore'
17
18
 
18
19
  const sessionLogger = createLogger('SESSION')
19
20
  const forkLogger = createLogger('FORK')
@@ -71,9 +72,15 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
71
72
 
72
73
  const sessionId = row.session_id
73
74
 
74
- try {
75
- const getClient = await initializeOpencodeForDirectory(directory)
75
+ const getClient = await initializeOpencodeForDirectory(directory)
76
+ if (getClient instanceof Error) {
77
+ await interaction.editReply({
78
+ content: `Failed to load messages: ${getClient.message}`,
79
+ })
80
+ return
81
+ }
76
82
 
83
+ try {
77
84
  const messagesResponse = await getClient().session.messages({
78
85
  path: { id: sessionId },
79
86
  })
@@ -85,7 +92,7 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
85
92
  return
86
93
  }
87
94
 
88
- const userMessages = messagesResponse.data.filter((m) => m.info.role === 'user')
95
+ const userMessages = messagesResponse.data.filter((m: { info: { role: string } }) => m.info.role === 'user')
89
96
 
90
97
  if (userMessages.length === 0) {
91
98
  await interaction.editReply({
@@ -96,8 +103,8 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
96
103
 
97
104
  const recentMessages = userMessages.slice(-25)
98
105
 
99
- const options = recentMessages.map((m, index) => {
100
- const textPart = m.parts.find((p) => p.type === 'text') as
106
+ const options = recentMessages.map((m: { parts: Array<{ type: string; text?: string }>; info: { id: string; time: { created: number } } }, index: number) => {
107
+ const textPart = m.parts.find((p: { type: string }) => p.type === 'text') as
101
108
  | { type: 'text'; text: string }
102
109
  | undefined
103
110
  const preview = textPart?.text?.slice(0, 80) || '(no text)'
@@ -163,9 +170,13 @@ export async function handleForkSelectMenu(
163
170
 
164
171
  await interaction.deferReply({ ephemeral: false })
165
172
 
166
- try {
167
- const getClient = await initializeOpencodeForDirectory(directory)
173
+ const getClient = await initializeOpencodeForDirectory(directory)
174
+ if (getClient instanceof Error) {
175
+ await interaction.editReply(`Failed to fork session: ${getClient.message}`)
176
+ return
177
+ }
168
178
 
179
+ try {
169
180
  const forkResponse = await getClient().session.fork({
170
181
  path: { id: sessionId },
171
182
  body: { messageID: selectedMessageId },
@@ -15,6 +15,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
15
15
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
16
16
  import { abortAndRetrySession } from '../session-handler.js'
17
17
  import { createLogger } from '../logger.js'
18
+ import * as errore from 'errore'
18
19
 
19
20
  const modelLogger = createLogger('MODEL')
20
21
 
@@ -128,6 +129,10 @@ export async function handleModelCommand({
128
129
 
129
130
  try {
130
131
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
132
+ if (getClient instanceof Error) {
133
+ await interaction.editReply({ content: getClient.message })
134
+ return
135
+ }
131
136
 
132
137
  const providersResponse = await getClient().provider.list({
133
138
  query: { directory: projectDirectory },
@@ -232,6 +237,13 @@ export async function handleProviderSelectMenu(
232
237
 
233
238
  try {
234
239
  const getClient = await initializeOpencodeForDirectory(context.dir)
240
+ if (getClient instanceof Error) {
241
+ await interaction.editReply({
242
+ content: getClient.message,
243
+ components: [],
244
+ })
245
+ return
246
+ }
235
247
 
236
248
  const providersResponse = await getClient().provider.list({
237
249
  query: { directory: context.dir },
@@ -1,6 +1,7 @@
1
1
  // /remove-project command - Remove Discord channels for a project.
2
2
 
3
3
  import path from 'node:path'
4
+ import * as errore from 'errore'
4
5
  import type { CommandContext, AutocompleteContext } from './types.js'
5
6
  import { getDatabase } from '../database.js'
6
7
  import { createLogger } from '../logger.js'
@@ -36,19 +37,27 @@ export async function handleRemoveProjectCommand({ command, appId }: CommandCont
36
37
  const failedChannels: string[] = []
37
38
 
38
39
  for (const { channel_id, channel_type } of channels) {
39
- try {
40
- const channel = await guild.channels.fetch(channel_id).catch(() => null)
41
-
42
- if (channel) {
40
+ const channel = await errore.tryAsync({
41
+ try: () => guild.channels.fetch(channel_id),
42
+ catch: (e) => e as Error,
43
+ })
44
+
45
+ if (channel instanceof Error) {
46
+ logger.error(`Failed to fetch channel ${channel_id}:`, channel)
47
+ failedChannels.push(`${channel_type}: ${channel_id}`)
48
+ continue
49
+ }
50
+
51
+ if (channel) {
52
+ try {
43
53
  await channel.delete(`Removed by /remove-project command`)
44
54
  deletedChannels.push(`${channel_type}: ${channel_id}`)
45
- } else {
46
- // Channel doesn't exist in this guild or was already deleted
47
- deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`)
55
+ } catch (error) {
56
+ logger.error(`Failed to delete channel ${channel_id}:`, error)
57
+ failedChannels.push(`${channel_type}: ${channel_id}`)
48
58
  }
49
- } catch (error) {
50
- logger.error(`Failed to delete channel ${channel_id}:`, error)
51
- failedChannels.push(`${channel_type}: ${channel_id}`)
59
+ } else {
60
+ deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`)
52
61
  }
53
62
  }
54
63
 
@@ -103,13 +112,16 @@ export async function handleRemoveProjectAutocomplete({
103
112
  const projectsInGuild: { directory: string; channelId: string }[] = []
104
113
 
105
114
  for (const { directory, channel_id } of allChannels) {
106
- try {
107
- const channel = await guild.channels.fetch(channel_id).catch(() => null)
108
- if (channel) {
109
- projectsInGuild.push({ directory, channelId: channel_id })
110
- }
111
- } catch {
115
+ const channel = await errore.tryAsync({
116
+ try: () => guild.channels.fetch(channel_id),
117
+ catch: (e) => e as Error,
118
+ })
119
+ if (channel instanceof Error) {
112
120
  // Channel not in this guild, skip
121
+ continue
122
+ }
123
+ if (channel) {
124
+ projectsInGuild.push({ directory, channelId: channel_id })
113
125
  }
114
126
  }
115
127
 
@@ -14,6 +14,7 @@ import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../dis
14
14
  import { extractTagsArrays } from '../xml.js'
15
15
  import { collectLastAssistantParts } from '../message-formatting.js'
16
16
  import { createLogger } from '../logger.js'
17
+ import * as errore from 'errore'
17
18
 
18
19
  const logger = createLogger('RESUME')
19
20
 
@@ -60,6 +61,10 @@ export async function handleResumeCommand({ command, appId }: CommandContext): P
60
61
 
61
62
  try {
62
63
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
64
+ if (getClient instanceof Error) {
65
+ await command.editReply(getClient.message)
66
+ return
67
+ }
63
68
 
64
69
  const sessionResponse = await getClient().session.get({
65
70
  path: { id: sessionId },
@@ -168,6 +173,10 @@ export async function handleResumeAutocomplete({
168
173
 
169
174
  try {
170
175
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
176
+ if (getClient instanceof Error) {
177
+ await interaction.respond([])
178
+ return
179
+ }
171
180
 
172
181
  const sessionsResponse = await getClient().session.list()
173
182
  if (!sessionsResponse.data) {
@@ -1,4 +1,4 @@
1
- // /session command - Start a new OpenCode session.
1
+ // /new-session command - Start a new OpenCode session.
2
2
 
3
3
  import { ChannelType, type TextChannel } from 'discord.js'
4
4
  import fs from 'node:fs'
@@ -10,6 +10,7 @@ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
10
10
  import { extractTagsArrays } from '../xml.js'
11
11
  import { handleOpencodeSession } from '../session-handler.js'
12
12
  import { createLogger } from '../logger.js'
13
+ import * as errore from 'errore'
13
14
 
14
15
  const logger = createLogger('SESSION')
15
16
 
@@ -58,6 +59,10 @@ export async function handleSessionCommand({ command, appId }: CommandContext):
58
59
 
59
60
  try {
60
61
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
62
+ if (getClient instanceof Error) {
63
+ await command.editReply(getClient.message)
64
+ return
65
+ }
61
66
 
62
67
  const files = filesString
63
68
  .split(',')
@@ -128,6 +133,10 @@ async function handleAgentAutocomplete({ interaction, appId }: AutocompleteConte
128
133
 
129
134
  try {
130
135
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
136
+ if (getClient instanceof Error) {
137
+ await interaction.respond([])
138
+ return
139
+ }
131
140
 
132
141
  const agentsResponse = await getClient().app.agents({
133
142
  query: { directory: projectDirectory },
@@ -207,6 +216,10 @@ export async function handleSessionAutocomplete({
207
216
 
208
217
  try {
209
218
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
219
+ if (getClient instanceof Error) {
220
+ await interaction.respond([])
221
+ return
222
+ }
210
223
 
211
224
  const response = await getClient().find.files({
212
225
  query: {
@@ -6,6 +6,7 @@ import { getDatabase } from '../database.js'
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js'
7
7
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
8
  import { createLogger } from '../logger.js'
9
+ import * as errore from 'errore'
9
10
 
10
11
  const logger = createLogger('SHARE')
11
12
 
@@ -63,8 +64,17 @@ export async function handleShareCommand({ command }: CommandContext): Promise<v
63
64
 
64
65
  const sessionId = row.session_id
65
66
 
67
+ const getClient = await initializeOpencodeForDirectory(directory)
68
+ if (getClient instanceof Error) {
69
+ await command.reply({
70
+ content: `Failed to share session: ${getClient.message}`,
71
+ ephemeral: true,
72
+ flags: SILENT_MESSAGE_FLAGS,
73
+ })
74
+ return
75
+ }
76
+
66
77
  try {
67
- const getClient = await initializeOpencodeForDirectory(directory)
68
78
  const response = await getClient().session.share({
69
79
  path: { id: sessionId },
70
80
  })
@@ -6,6 +6,7 @@ import { getDatabase } from '../database.js'
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js'
7
7
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
8
  import { createLogger } from '../logger.js'
9
+ import * as errore from 'errore'
9
10
 
10
11
  const logger = createLogger('UNDO-REDO')
11
12
 
@@ -63,11 +64,15 @@ export async function handleUndoCommand({ command }: CommandContext): Promise<vo
63
64
 
64
65
  const sessionId = row.session_id
65
66
 
66
- try {
67
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
67
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
68
68
 
69
- const getClient = await initializeOpencodeForDirectory(directory)
69
+ const getClient = await initializeOpencodeForDirectory(directory)
70
+ if (getClient instanceof Error) {
71
+ await command.editReply(`Failed to undo: ${getClient.message}`)
72
+ return
73
+ }
70
74
 
75
+ try {
71
76
  // Fetch messages to find the last assistant message
72
77
  const messagesResponse = await getClient().session.messages({
73
78
  path: { id: sessionId },
@@ -166,11 +171,15 @@ export async function handleRedoCommand({ command }: CommandContext): Promise<vo
166
171
 
167
172
  const sessionId = row.session_id
168
173
 
169
- try {
170
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
174
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
171
175
 
172
- const getClient = await initializeOpencodeForDirectory(directory)
176
+ const getClient = await initializeOpencodeForDirectory(directory)
177
+ if (getClient instanceof Error) {
178
+ await command.editReply(`Failed to redo: ${getClient.message}`)
179
+ return
180
+ }
173
181
 
182
+ try {
174
183
  // Check if session has reverted state
175
184
  const sessionResponse = await getClient().session.get({
176
185
  path: { id: sessionId },
@@ -0,0 +1,243 @@
1
+ // Worktree management command: /new-worktree
2
+ // Uses OpenCode SDK v2 to create worktrees with kimaki- prefix
3
+ // Creates thread immediately, then worktree in background so user can type
4
+
5
+ import { ChannelType, type TextChannel, type ThreadChannel, type Message } from 'discord.js'
6
+ import fs from 'node:fs'
7
+ import type { CommandContext } from './types.js'
8
+ import {
9
+ createPendingWorktree,
10
+ setWorktreeReady,
11
+ setWorktreeError,
12
+ } from '../database.js'
13
+ import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js'
14
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
15
+ import { extractTagsArrays } from '../xml.js'
16
+ import { createLogger } from '../logger.js'
17
+ import * as errore from 'errore'
18
+
19
+ const logger = createLogger('WORKTREE')
20
+
21
+ class WorktreeError extends Error {
22
+ constructor(message: string, options?: { cause?: unknown }) {
23
+ super(message, options)
24
+ this.name = 'WorktreeError'
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Format worktree name: lowercase, spaces to dashes, remove special chars, add kimaki- prefix.
30
+ * "My Feature" → "kimaki-my-feature"
31
+ */
32
+ function formatWorktreeName(name: string): string {
33
+ const formatted = name
34
+ .toLowerCase()
35
+ .trim()
36
+ .replace(/\s+/g, '-')
37
+ .replace(/[^a-z0-9-]/g, '')
38
+
39
+ return `kimaki-${formatted}`
40
+ }
41
+
42
+ /**
43
+ * Get project directory from channel topic.
44
+ */
45
+ function getProjectDirectoryFromChannel(
46
+ channel: TextChannel,
47
+ appId: string,
48
+ ): string | WorktreeError {
49
+ if (!channel.topic) {
50
+ return new WorktreeError('This channel has no topic configured')
51
+ }
52
+
53
+ const extracted = extractTagsArrays({
54
+ xml: channel.topic,
55
+ tags: ['kimaki.directory', 'kimaki.app'],
56
+ })
57
+
58
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
59
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
60
+
61
+ if (channelAppId && channelAppId !== appId) {
62
+ return new WorktreeError('This channel is not configured for this bot')
63
+ }
64
+
65
+ if (!projectDirectory) {
66
+ return new WorktreeError('This channel is not configured with a project directory')
67
+ }
68
+
69
+ if (!fs.existsSync(projectDirectory)) {
70
+ return new WorktreeError(`Directory does not exist: ${projectDirectory}`)
71
+ }
72
+
73
+ return projectDirectory
74
+ }
75
+
76
+ /**
77
+ * Create worktree in background and update starter message when done.
78
+ */
79
+ async function createWorktreeInBackground({
80
+ thread,
81
+ starterMessage,
82
+ worktreeName,
83
+ projectDirectory,
84
+ clientV2,
85
+ }: {
86
+ thread: ThreadChannel
87
+ starterMessage: Message
88
+ worktreeName: string
89
+ projectDirectory: string
90
+ clientV2: ReturnType<typeof getOpencodeClientV2> & {}
91
+ }): Promise<void> {
92
+ // Create worktree using SDK v2
93
+ 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 }),
114
+ })
115
+
116
+ if (errore.isError(worktreeResult)) {
117
+ const errorMsg = worktreeResult.message
118
+ logger.error('[NEW-WORKTREE] Error:', worktreeResult.cause)
119
+ setWorktreeError({ threadId: thread.id, errorMessage: errorMsg })
120
+ await starterMessage.edit(`🌳 **Worktree: ${worktreeName}**\n❌ ${errorMsg}`)
121
+ return
122
+ }
123
+
124
+ // Success - update database and edit starter message
125
+ setWorktreeReady({ threadId: thread.id, worktreeDirectory: worktreeResult.directory })
126
+ await starterMessage.edit(
127
+ `🌳 **Worktree: ${worktreeName}**\n` +
128
+ `📁 \`${worktreeResult.directory}\`\n` +
129
+ `🌿 Branch: \`${worktreeResult.branch}\``
130
+ )
131
+ }
132
+
133
+ export async function handleNewWorktreeCommand({
134
+ command,
135
+ appId,
136
+ }: CommandContext): Promise<void> {
137
+ await command.deferReply({ ephemeral: false })
138
+
139
+ const rawName = command.options.getString('name', true)
140
+ const worktreeName = formatWorktreeName(rawName)
141
+
142
+ if (worktreeName === 'kimaki-') {
143
+ await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.')
144
+ return
145
+ }
146
+
147
+ const channel = command.channel
148
+
149
+ if (!channel || channel.type !== ChannelType.GuildText) {
150
+ await command.editReply('This command can only be used in text channels')
151
+ return
152
+ }
153
+
154
+ const textChannel = channel as TextChannel
155
+
156
+ const projectDirectory = getProjectDirectoryFromChannel(textChannel, appId)
157
+ if (errore.isError(projectDirectory)) {
158
+ await command.editReply(projectDirectory.message)
159
+ return
160
+ }
161
+
162
+ // Initialize opencode and check if worktree already exists
163
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
164
+ if (errore.isError(getClient)) {
165
+ await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`)
166
+ return
167
+ }
168
+
169
+ const clientV2 = getOpencodeClientV2(projectDirectory)
170
+ if (!clientV2) {
171
+ await command.editReply('Failed to get OpenCode client')
172
+ return
173
+ }
174
+
175
+ // Check if worktree with this name already exists
176
+ // SDK returns array of directory paths like "~/.opencode/worktree/abc/kimaki-my-feature"
177
+ const listResult = await errore.tryAsync({
178
+ try: async () => {
179
+ const response = await clientV2.worktree.list({ directory: projectDirectory })
180
+ return response.data || []
181
+ },
182
+ catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
183
+ })
184
+
185
+ if (errore.isError(listResult)) {
186
+ await command.editReply(listResult.message)
187
+ return
188
+ }
189
+
190
+ // Check if any worktree path ends with our name
191
+ const existingWorktree = listResult.find((dir) => dir.endsWith(`/${worktreeName}`))
192
+ if (existingWorktree) {
193
+ await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktree}\``)
194
+ return
195
+ }
196
+
197
+ // Create thread immediately so user can start typing
198
+ const result = await errore.tryAsync({
199
+ try: async () => {
200
+ const starterMessage = await textChannel.send({
201
+ content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...`,
202
+ flags: SILENT_MESSAGE_FLAGS,
203
+ })
204
+
205
+ const thread = await starterMessage.startThread({
206
+ name: `worktree: ${worktreeName}`,
207
+ autoArchiveDuration: 1440,
208
+ reason: 'Worktree session',
209
+ })
210
+
211
+ return { thread, starterMessage }
212
+ },
213
+ catch: (e) => new WorktreeError('Failed to create thread', { cause: e }),
214
+ })
215
+
216
+ if (errore.isError(result)) {
217
+ logger.error('[NEW-WORKTREE] Error:', result.cause)
218
+ await command.editReply(result.message)
219
+ return
220
+ }
221
+
222
+ const { thread, starterMessage } = result
223
+
224
+ // Store pending worktree in database
225
+ createPendingWorktree({
226
+ threadId: thread.id,
227
+ worktreeName,
228
+ projectDirectory,
229
+ })
230
+
231
+ await command.editReply(`Creating worktree in ${thread.toString()}`)
232
+
233
+ // Create worktree in background (don't await)
234
+ createWorktreeInBackground({
235
+ thread,
236
+ starterMessage,
237
+ worktreeName,
238
+ projectDirectory,
239
+ clientV2,
240
+ }).catch((e) => {
241
+ logger.error('[NEW-WORKTREE] Background error:', e)
242
+ })
243
+ }
package/src/database.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import Database from 'better-sqlite3'
6
6
  import fs from 'node:fs'
7
7
  import path from 'node:path'
8
+ import * as errore from 'errore'
8
9
  import { createLogger } from './logger.js'
9
10
  import { getDataDir } from './config.js'
10
11
 
@@ -16,10 +17,14 @@ export function getDatabase(): Database.Database {
16
17
  if (!db) {
17
18
  const dataDir = getDataDir()
18
19
 
19
- try {
20
- fs.mkdirSync(dataDir, { recursive: true })
21
- } catch (error) {
22
- dbLogger.error(`Failed to create data directory ${dataDir}:`, error)
20
+ const mkdirError = errore.tryFn({
21
+ try: () => {
22
+ fs.mkdirSync(dataDir, { recursive: true })
23
+ },
24
+ catch: (e) => e as Error,
25
+ })
26
+ if (mkdirError instanceof Error) {
27
+ dbLogger.error(`Failed to create data directory ${dataDir}:`, mkdirError.message)
23
28
  }
24
29
 
25
30
  const dbPath = path.join(dataDir, 'discord-sessions.db')
@@ -85,6 +90,20 @@ export function getDatabase(): Database.Database {
85
90
  )
86
91
  `)
87
92
 
93
+ // Track worktrees created for threads (for /new-worktree command)
94
+ // status: 'pending' while creating, 'ready' when done, 'error' if failed
95
+ db.exec(`
96
+ CREATE TABLE IF NOT EXISTS thread_worktrees (
97
+ thread_id TEXT PRIMARY KEY,
98
+ worktree_name TEXT NOT NULL,
99
+ worktree_directory TEXT,
100
+ project_directory TEXT NOT NULL,
101
+ status TEXT DEFAULT 'pending',
102
+ error_message TEXT,
103
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
104
+ )
105
+ `)
106
+
88
107
  runModelMigrations(db)
89
108
  }
90
109
 
@@ -238,6 +257,87 @@ export function setSessionAgent(sessionId: string, agentName: string): void {
238
257
  )
239
258
  }
240
259
 
260
+ // Worktree status types
261
+ export type WorktreeStatus = 'pending' | 'ready' | 'error'
262
+
263
+ export type ThreadWorktree = {
264
+ thread_id: string
265
+ worktree_name: string
266
+ worktree_directory: string | null
267
+ project_directory: string
268
+ status: WorktreeStatus
269
+ error_message: string | null
270
+ }
271
+
272
+ /**
273
+ * Get the worktree info for a thread.
274
+ */
275
+ export function getThreadWorktree(threadId: string): ThreadWorktree | undefined {
276
+ const db = getDatabase()
277
+ return db.prepare('SELECT * FROM thread_worktrees WHERE thread_id = ?').get(threadId) as
278
+ | ThreadWorktree
279
+ | undefined
280
+ }
281
+
282
+ /**
283
+ * Create a pending worktree entry for a thread.
284
+ */
285
+ export function createPendingWorktree({
286
+ threadId,
287
+ worktreeName,
288
+ projectDirectory,
289
+ }: {
290
+ threadId: string
291
+ worktreeName: string
292
+ projectDirectory: string
293
+ }): void {
294
+ const db = getDatabase()
295
+ db.prepare(
296
+ `INSERT OR REPLACE INTO thread_worktrees (thread_id, worktree_name, project_directory, status) VALUES (?, ?, ?, 'pending')`,
297
+ ).run(threadId, worktreeName, projectDirectory)
298
+ }
299
+
300
+ /**
301
+ * Mark a worktree as ready with its directory.
302
+ */
303
+ export function setWorktreeReady({
304
+ threadId,
305
+ worktreeDirectory,
306
+ }: {
307
+ threadId: string
308
+ worktreeDirectory: string
309
+ }): void {
310
+ const db = getDatabase()
311
+ db.prepare(
312
+ `UPDATE thread_worktrees SET worktree_directory = ?, status = 'ready' WHERE thread_id = ?`,
313
+ ).run(worktreeDirectory, threadId)
314
+ }
315
+
316
+ /**
317
+ * Mark a worktree as failed with error message.
318
+ */
319
+ export function setWorktreeError({
320
+ threadId,
321
+ errorMessage,
322
+ }: {
323
+ threadId: string
324
+ errorMessage: string
325
+ }): void {
326
+ const db = getDatabase()
327
+ db.prepare(`UPDATE thread_worktrees SET status = 'error', error_message = ? WHERE thread_id = ?`).run(
328
+ errorMessage,
329
+ threadId,
330
+ )
331
+ }
332
+
333
+ /**
334
+ * Delete the worktree info for a thread.
335
+ */
336
+ export function deleteThreadWorktree(threadId: string): void {
337
+ const db = getDatabase()
338
+ db.prepare('DELETE FROM thread_worktrees WHERE thread_id = ?').run(threadId)
339
+ }
340
+
241
341
  export function closeDatabase(): void {
242
342
  if (db) {
243
343
  db.close()