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
@@ -18,6 +18,7 @@ const logger = createLogger('PERMISSIONS')
18
18
 
19
19
  type PendingPermissionContext = {
20
20
  permission: PermissionRequest
21
+ requestIds: string[]
21
22
  directory: string
22
23
  thread: ThreadChannel
23
24
  contextHash: string
@@ -43,6 +44,7 @@ export async function showPermissionDropdown({
43
44
 
44
45
  const context: PendingPermissionContext = {
45
46
  permission,
47
+ requestIds: [permission.id],
46
48
  directory,
47
49
  thread,
48
50
  contextHash,
@@ -124,10 +126,15 @@ export async function handlePermissionSelectMenu(
124
126
  if (!clientV2) {
125
127
  throw new Error('OpenCode server not found for directory')
126
128
  }
127
- await clientV2.permission.reply({
128
- requestID: context.permission.id,
129
- reply: response,
130
- })
129
+ const requestIds = context.requestIds.length > 0 ? context.requestIds : [context.permission.id]
130
+ await Promise.all(
131
+ requestIds.map((requestId) => {
132
+ return clientV2.permission.reply({
133
+ requestID: requestId,
134
+ reply: response,
135
+ })
136
+ }),
137
+ )
131
138
 
132
139
  pendingPermissionContexts.delete(contextHash)
133
140
 
@@ -153,7 +160,7 @@ export async function handlePermissionSelectMenu(
153
160
  components: [], // Remove the dropdown
154
161
  })
155
162
 
156
- logger.log(`Permission ${context.permission.id} ${response}`)
163
+ logger.log(`Permission ${context.permission.id} ${response} (${requestIds.length} request(s))`)
157
164
  } catch (error) {
158
165
  logger.error('Error handling permission:', error)
159
166
  await interaction.editReply({
@@ -163,6 +170,25 @@ export async function handlePermissionSelectMenu(
163
170
  }
164
171
  }
165
172
 
173
+ export function addPermissionRequestToContext({
174
+ contextHash,
175
+ requestId,
176
+ }: {
177
+ contextHash: string
178
+ requestId: string
179
+ }): boolean {
180
+ const context = pendingPermissionContexts.get(contextHash)
181
+ if (!context) {
182
+ return false
183
+ }
184
+ if (context.requestIds.includes(requestId)) {
185
+ return false
186
+ }
187
+ context.requestIds = [...context.requestIds, requestId]
188
+ pendingPermissionContexts.set(contextHash, context)
189
+ return true
190
+ }
191
+
166
192
  /**
167
193
  * Clean up a pending permission context (e.g., on auto-reject).
168
194
  */
@@ -62,7 +62,11 @@ export async function handleQueueCommand({ command }: CommandContext): Promise<v
62
62
  }
63
63
 
64
64
  // Check if there's an active request running
65
- const hasActiveRequest = abortControllers.has(row.session_id)
65
+ const existingController = abortControllers.get(row.session_id)
66
+ const hasActiveRequest = Boolean(existingController && !existingController.signal.aborted)
67
+ if (existingController && existingController.signal.aborted) {
68
+ abortControllers.delete(row.session_id)
69
+ }
66
70
 
67
71
  if (!hasActiveRequest) {
68
72
  // No active request, send immediately
@@ -8,10 +8,9 @@ import {
8
8
  } from 'discord.js'
9
9
  import fs from 'node:fs'
10
10
  import type { CommandContext, AutocompleteContext } from './types.js'
11
- import { getDatabase } from '../database.js'
11
+ import { getDatabase, getChannelDirectory } from '../database.js'
12
12
  import { initializeOpencodeForDirectory } from '../opencode.js'
13
- import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
14
- import { extractTagsArrays } from '../xml.js'
13
+ import { sendThreadMessage, resolveTextChannel } from '../discord-utils.js'
15
14
  import { collectLastAssistantParts } from '../message-formatting.js'
16
15
  import { createLogger } from '../logger.js'
17
16
  import * as errore from 'errore'
@@ -31,18 +30,9 @@ export async function handleResumeCommand({ command, appId }: CommandContext): P
31
30
 
32
31
  const textChannel = channel as TextChannel
33
32
 
34
- let projectDirectory: string | undefined
35
- let channelAppId: string | undefined
36
-
37
- if (textChannel.topic) {
38
- const extracted = extractTagsArrays({
39
- xml: textChannel.topic,
40
- tags: ['kimaki.directory', 'kimaki.app'],
41
- })
42
-
43
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
44
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
45
- }
33
+ const channelConfig = getChannelDirectory(textChannel.id)
34
+ const projectDirectory = channelConfig?.directory
35
+ const channelAppId = channelConfig?.appId || undefined
46
36
 
47
37
  if (channelAppId && channelAppId !== appId) {
48
38
  await command.editReply('This channel is not configured for this bot')
@@ -157,12 +147,12 @@ export async function handleResumeAutocomplete({
157
147
  interaction.channel as TextChannel | ThreadChannel | null,
158
148
  )
159
149
  if (textChannel) {
160
- const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel)
161
- if (channelAppId && channelAppId !== appId) {
150
+ const channelConfig = getChannelDirectory(textChannel.id)
151
+ if (channelConfig?.appId && channelConfig.appId !== appId) {
162
152
  await interaction.respond([])
163
153
  return
164
154
  }
165
- projectDirectory = directory
155
+ projectDirectory = channelConfig?.directory
166
156
  }
167
157
  }
168
158
 
@@ -4,10 +4,9 @@ import { ChannelType, type TextChannel } from 'discord.js'
4
4
  import fs from 'node:fs'
5
5
  import path from 'node:path'
6
6
  import type { CommandContext, AutocompleteContext } from './types.js'
7
- import { getDatabase } from '../database.js'
7
+ import { getDatabase, getChannelDirectory } from '../database.js'
8
8
  import { initializeOpencodeForDirectory } from '../opencode.js'
9
9
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
10
- import { extractTagsArrays } from '../xml.js'
11
10
  import { handleOpencodeSession } from '../session-handler.js'
12
11
  import { createLogger } from '../logger.js'
13
12
  import * as errore from 'errore'
@@ -29,18 +28,9 @@ export async function handleSessionCommand({ command, appId }: CommandContext):
29
28
 
30
29
  const textChannel = channel as TextChannel
31
30
 
32
- let projectDirectory: string | undefined
33
- let channelAppId: string | undefined
34
-
35
- if (textChannel.topic) {
36
- const extracted = extractTagsArrays({
37
- xml: textChannel.topic,
38
- tags: ['kimaki.directory', 'kimaki.app'],
39
- })
40
-
41
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
42
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
43
- }
31
+ const channelConfig = getChannelDirectory(textChannel.id)
32
+ const projectDirectory = channelConfig?.directory
33
+ const channelAppId = channelConfig?.appId || undefined
44
34
 
45
35
  if (channelAppId && channelAppId !== appId) {
46
36
  await command.editReply('This channel is not configured for this bot')
@@ -107,22 +97,14 @@ async function handleAgentAutocomplete({ interaction, appId }: AutocompleteConte
107
97
 
108
98
  let projectDirectory: string | undefined
109
99
 
110
- if (interaction.channel) {
111
- const channel = interaction.channel
112
- if (channel.type === ChannelType.GuildText) {
113
- const textChannel = channel as TextChannel
114
- if (textChannel.topic) {
115
- const extracted = extractTagsArrays({
116
- xml: textChannel.topic,
117
- tags: ['kimaki.directory', 'kimaki.app'],
118
- })
119
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
120
- if (channelAppId && channelAppId !== appId) {
121
- await interaction.respond([])
122
- return
123
- }
124
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
100
+ if (interaction.channel && interaction.channel.type === ChannelType.GuildText) {
101
+ const channelConfig = getChannelDirectory(interaction.channel.id)
102
+ if (channelConfig) {
103
+ if (channelConfig.appId && channelConfig.appId !== appId) {
104
+ await interaction.respond([])
105
+ return
125
106
  }
107
+ projectDirectory = channelConfig.directory
126
108
  }
127
109
  }
128
110
 
@@ -190,22 +172,14 @@ export async function handleSessionAutocomplete({
190
172
 
191
173
  let projectDirectory: string | undefined
192
174
 
193
- if (interaction.channel) {
194
- const channel = interaction.channel
195
- if (channel.type === ChannelType.GuildText) {
196
- const textChannel = channel as TextChannel
197
- if (textChannel.topic) {
198
- const extracted = extractTagsArrays({
199
- xml: textChannel.topic,
200
- tags: ['kimaki.directory', 'kimaki.app'],
201
- })
202
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
203
- if (channelAppId && channelAppId !== appId) {
204
- await interaction.respond([])
205
- return
206
- }
207
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
175
+ if (interaction.channel && interaction.channel.type === ChannelType.GuildText) {
176
+ const channelConfig = getChannelDirectory(interaction.channel.id)
177
+ if (channelConfig) {
178
+ if (channelConfig.appId && channelConfig.appId !== appId) {
179
+ await interaction.respond([])
180
+ return
208
181
  }
182
+ projectDirectory = channelConfig.directory
209
183
  }
210
184
  }
211
185
 
@@ -3,11 +3,10 @@
3
3
 
4
4
  import type { CommandContext, CommandHandler } from './types.js'
5
5
  import { ChannelType, type TextChannel, type ThreadChannel } from 'discord.js'
6
- import { extractTagsArrays } from '../xml.js'
7
6
  import { handleOpencodeSession } from '../session-handler.js'
8
7
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
9
8
  import { createLogger } from '../logger.js'
10
- import { getDatabase } from '../database.js'
9
+ import { getDatabase, getChannelDirectory } from '../database.js'
11
10
  import fs from 'node:fs'
12
11
 
13
12
  const userCommandLogger = createLogger('USER_CMD')
@@ -68,28 +67,18 @@ export const handleUserCommand: CommandHandler = async ({ command, appId }: Comm
68
67
  return
69
68
  }
70
69
 
71
- if (textChannel?.topic) {
72
- const extracted = extractTagsArrays({
73
- xml: textChannel.topic,
74
- tags: ['kimaki.directory', 'kimaki.app'],
75
- })
76
-
77
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
78
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
70
+ if (textChannel) {
71
+ const channelConfig = getChannelDirectory(textChannel.id)
72
+ projectDirectory = channelConfig?.directory
73
+ channelAppId = channelConfig?.appId || undefined
79
74
  }
80
75
  } else {
81
76
  // Running in a text channel - will create a new thread
82
77
  textChannel = channel as TextChannel
83
78
 
84
- if (textChannel.topic) {
85
- const extracted = extractTagsArrays({
86
- xml: textChannel.topic,
87
- tags: ['kimaki.directory', 'kimaki.app'],
88
- })
89
-
90
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
91
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
92
- }
79
+ const channelConfig = getChannelDirectory(textChannel.id)
80
+ projectDirectory = channelConfig?.directory
81
+ channelAppId = channelConfig?.appId || undefined
93
82
  }
94
83
 
95
84
  if (channelAppId && channelAppId !== appId) {
@@ -0,0 +1,71 @@
1
+ // /verbosity command.
2
+ // Sets the output verbosity level for sessions in a channel.
3
+ // 'tools-and-text' (default): shows all output including tool executions
4
+ // 'text-only': only shows text responses (⬥ diamond parts)
5
+
6
+ import { ChatInputCommandInteraction, ChannelType, type TextChannel, type ThreadChannel } from 'discord.js'
7
+ import { getChannelVerbosity, setChannelVerbosity, type VerbosityLevel } from '../database.js'
8
+ import { createLogger } from '../logger.js'
9
+
10
+ const verbosityLogger = createLogger('VERBOSITY')
11
+
12
+ /**
13
+ * Handle the /verbosity slash command.
14
+ * Sets output verbosity for the channel (applies to new sessions).
15
+ */
16
+ export async function handleVerbosityCommand({
17
+ command,
18
+ appId,
19
+ }: {
20
+ command: ChatInputCommandInteraction
21
+ appId: string
22
+ }): Promise<void> {
23
+ verbosityLogger.log('[VERBOSITY] Command called')
24
+
25
+ const channel = command.channel
26
+ if (!channel) {
27
+ await command.reply({
28
+ content: 'Could not determine channel.',
29
+ ephemeral: true,
30
+ })
31
+ return
32
+ }
33
+
34
+ // Get the parent channel ID (for threads, use parent; for text channels, use self)
35
+ const channelId = (() => {
36
+ if (channel.type === ChannelType.GuildText) {
37
+ return channel.id
38
+ }
39
+ if (
40
+ channel.type === ChannelType.PublicThread ||
41
+ channel.type === ChannelType.PrivateThread ||
42
+ channel.type === ChannelType.AnnouncementThread
43
+ ) {
44
+ return (channel as ThreadChannel).parentId || channel.id
45
+ }
46
+ return channel.id
47
+ })()
48
+
49
+ const level = command.options.getString('level', true) as VerbosityLevel
50
+ const currentLevel = getChannelVerbosity(channelId)
51
+
52
+ if (currentLevel === level) {
53
+ await command.reply({
54
+ content: `Verbosity is already set to **${level}**.`,
55
+ ephemeral: true,
56
+ })
57
+ return
58
+ }
59
+
60
+ setChannelVerbosity(channelId, level)
61
+ verbosityLogger.log(`[VERBOSITY] Set channel ${channelId} to ${level}`)
62
+
63
+ const description = level === 'text-only'
64
+ ? 'Only text responses will be shown. Tool executions, status messages, and thinking will be hidden.'
65
+ : 'All output will be shown, including tool executions and status messages.'
66
+
67
+ await command.reply({
68
+ content: `Verbosity set to **${level}**.\n\n${description}\n\nThis applies to all new sessions in this channel.`,
69
+ ephemeral: true,
70
+ })
71
+ }
@@ -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.\nUse `/add-project` to set up this channel.',
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.\nUse `/add-project` to set up this channel.',
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
+ }