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
@@ -9,11 +9,14 @@ import {
9
9
  createPendingWorktree,
10
10
  setWorktreeReady,
11
11
  setWorktreeError,
12
+ getChannelDirectory,
13
+ getThreadWorktree,
12
14
  } from '../database.js'
13
15
  import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js'
14
16
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
15
- import { extractTagsArrays } from '../xml.js'
16
17
  import { createLogger } from '../logger.js'
18
+ import { createWorktreeWithSubmodules } from '../worktree-utils.js'
19
+ import { WORKTREE_PREFIX } from './merge-worktree.js'
17
20
  import * as errore from 'errore'
18
21
 
19
22
  const logger = createLogger('WORKTREE')
@@ -26,51 +29,64 @@ class WorktreeError extends Error {
26
29
  }
27
30
 
28
31
  /**
29
- * Format worktree name: lowercase, spaces to dashes, remove special chars, add kimaki- prefix.
30
- * "My Feature" → "kimaki-my-feature"
32
+ * Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/kimaki- prefix.
33
+ * "My Feature" → "opencode/kimaki-my-feature"
34
+ * Returns empty string if no valid name can be extracted.
31
35
  */
32
- function formatWorktreeName(name: string): string {
36
+ export function formatWorktreeName(name: string): string {
33
37
  const formatted = name
34
38
  .toLowerCase()
35
39
  .trim()
36
40
  .replace(/\s+/g, '-')
37
41
  .replace(/[^a-z0-9-]/g, '')
38
42
 
39
- return `kimaki-${formatted}`
43
+ if (!formatted) {
44
+ return ''
45
+ }
46
+ return `opencode/kimaki-${formatted}`
40
47
  }
41
48
 
42
49
  /**
43
- * Get project directory from channel topic.
50
+ * Derive worktree name from thread name.
51
+ * Handles existing "⬦ worktree: opencode/kimaki-name" format or uses thread name directly.
52
+ */
53
+ function deriveWorktreeNameFromThread(threadName: string): string {
54
+ // Handle existing "⬦ worktree: opencode/kimaki-name" format
55
+ const worktreeMatch = threadName.match(/worktree:\s*(.+)$/i)
56
+ const extractedName = worktreeMatch?.[1]?.trim()
57
+ if (extractedName) {
58
+ // If already has opencode/kimaki- prefix, return as is
59
+ if (extractedName.startsWith('opencode/kimaki-')) {
60
+ return extractedName
61
+ }
62
+ return formatWorktreeName(extractedName)
63
+ }
64
+ // Use thread name directly
65
+ return formatWorktreeName(threadName)
66
+ }
67
+
68
+ /**
69
+ * Get project directory from database.
44
70
  */
45
71
  function getProjectDirectoryFromChannel(
46
72
  channel: TextChannel,
47
73
  appId: string,
48
74
  ): string | WorktreeError {
49
- if (!channel.topic) {
50
- return new WorktreeError('This channel has no topic configured')
51
- }
75
+ const channelConfig = getChannelDirectory(channel.id)
52
76
 
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')
77
+ if (!channelConfig) {
78
+ return new WorktreeError('This channel is not configured with a project directory')
63
79
  }
64
80
 
65
- if (!projectDirectory) {
66
- return new WorktreeError('This channel is not configured with a project directory')
81
+ if (channelConfig.appId && channelConfig.appId !== appId) {
82
+ return new WorktreeError('This channel is not configured for this bot')
67
83
  }
68
84
 
69
- if (!fs.existsSync(projectDirectory)) {
70
- return new WorktreeError(`Directory does not exist: ${projectDirectory}`)
85
+ if (!fs.existsSync(channelConfig.directory)) {
86
+ return new WorktreeError(`Directory does not exist: ${channelConfig.directory}`)
71
87
  }
72
88
 
73
- return projectDirectory
89
+ return channelConfig.directory
74
90
  }
75
91
 
76
92
  /**
@@ -89,33 +105,17 @@ async function createWorktreeInBackground({
89
105
  projectDirectory: string
90
106
  clientV2: ReturnType<typeof getOpencodeClientV2> & {}
91
107
  }): Promise<void> {
92
- // Create worktree using SDK v2
108
+ // Create worktree using SDK v2 and init submodules
93
109
  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 }),
110
+ const worktreeResult = await createWorktreeWithSubmodules({
111
+ clientV2,
112
+ directory: projectDirectory,
113
+ name: worktreeName,
114
114
  })
115
115
 
116
- if (errore.isError(worktreeResult)) {
116
+ if (worktreeResult instanceof Error) {
117
117
  const errorMsg = worktreeResult.message
118
- logger.error('[NEW-WORKTREE] Error:', worktreeResult.cause)
118
+ logger.error('[NEW-WORKTREE] Error:', worktreeResult)
119
119
  setWorktreeError({ threadId: thread.id, errorMessage: errorMsg })
120
120
  await starterMessage.edit(`🌳 **Worktree: ${worktreeName}**\n❌ ${errorMsg}`)
121
121
  return
@@ -136,18 +136,38 @@ export async function handleNewWorktreeCommand({
136
136
  }: CommandContext): Promise<void> {
137
137
  await command.deferReply({ ephemeral: false })
138
138
 
139
- const rawName = command.options.getString('name', true)
140
- const worktreeName = formatWorktreeName(rawName)
139
+ const channel = command.channel
140
+ if (!channel) {
141
+ await command.editReply('Cannot determine channel')
142
+ return
143
+ }
141
144
 
142
- if (worktreeName === 'kimaki-') {
143
- await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.')
145
+ const isThread =
146
+ channel.type === ChannelType.PublicThread || channel.type === ChannelType.PrivateThread
147
+
148
+ // Handle command in existing thread - attach worktree to this thread
149
+ if (isThread) {
150
+ await handleWorktreeInThread({ command, appId, thread: channel as ThreadChannel })
144
151
  return
145
152
  }
146
153
 
147
- const channel = command.channel
154
+ // Handle command in text channel - create new thread with worktree (existing behavior)
155
+ if (channel.type !== ChannelType.GuildText) {
156
+ await command.editReply('This command can only be used in text channels or threads')
157
+ return
158
+ }
148
159
 
149
- if (!channel || channel.type !== ChannelType.GuildText) {
150
- await command.editReply('This command can only be used in text channels')
160
+ const rawName = command.options.getString('name')
161
+ if (!rawName) {
162
+ await command.editReply(
163
+ 'Name is required when creating a worktree from a text channel. Use `/new-worktree name:my-feature`',
164
+ )
165
+ return
166
+ }
167
+
168
+ const worktreeName = formatWorktreeName(rawName)
169
+ if (!worktreeName) {
170
+ await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.')
151
171
  return
152
172
  }
153
173
 
@@ -203,7 +223,7 @@ export async function handleNewWorktreeCommand({
203
223
  })
204
224
 
205
225
  const thread = await starterMessage.startThread({
206
- name: `worktree: ${worktreeName}`,
226
+ name: `${WORKTREE_PREFIX}worktree: ${worktreeName}`,
207
227
  autoArchiveDuration: 1440,
208
228
  reason: 'Worktree session',
209
229
  })
@@ -241,3 +261,102 @@ export async function handleNewWorktreeCommand({
241
261
  logger.error('[NEW-WORKTREE] Background error:', e)
242
262
  })
243
263
  }
264
+
265
+ /**
266
+ * Handle /new-worktree when called inside an existing thread.
267
+ * Attaches a worktree to the current thread, using thread name if no name provided.
268
+ */
269
+ async function handleWorktreeInThread({
270
+ command,
271
+ appId,
272
+ thread,
273
+ }: CommandContext & { thread: ThreadChannel }): Promise<void> {
274
+ // Error if thread already has a worktree
275
+ if (getThreadWorktree(thread.id)) {
276
+ await command.editReply('This thread already has a worktree attached.')
277
+ return
278
+ }
279
+
280
+ // Get worktree name from parameter or derive from thread name
281
+ const rawName = command.options.getString('name')
282
+ const worktreeName = rawName ? formatWorktreeName(rawName) : deriveWorktreeNameFromThread(thread.name)
283
+
284
+ if (!worktreeName) {
285
+ await command.editReply('Invalid worktree name. Please provide a name or rename the thread.')
286
+ return
287
+ }
288
+
289
+ // Get parent channel for project directory
290
+ const parent = thread.parent
291
+ if (!parent || parent.type !== ChannelType.GuildText) {
292
+ await command.editReply('Cannot determine parent channel')
293
+ return
294
+ }
295
+
296
+ const projectDirectory = getProjectDirectoryFromChannel(parent as TextChannel, appId)
297
+ if (errore.isError(projectDirectory)) {
298
+ await command.editReply(projectDirectory.message)
299
+ return
300
+ }
301
+
302
+ // Initialize opencode
303
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
304
+ if (errore.isError(getClient)) {
305
+ await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`)
306
+ return
307
+ }
308
+
309
+ const clientV2 = getOpencodeClientV2(projectDirectory)
310
+ if (!clientV2) {
311
+ await command.editReply('Failed to get OpenCode client')
312
+ return
313
+ }
314
+
315
+ // Check if worktree with this name already exists
316
+ const listResult = await errore.tryAsync({
317
+ try: async () => {
318
+ const response = await clientV2.worktree.list({ directory: projectDirectory })
319
+ return response.data || []
320
+ },
321
+ catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
322
+ })
323
+
324
+ if (errore.isError(listResult)) {
325
+ await command.editReply(listResult.message)
326
+ return
327
+ }
328
+
329
+ const existingWorktreePath = listResult.find((dir) => dir.endsWith(`/${worktreeName}`))
330
+ if (existingWorktreePath) {
331
+ await command.editReply(
332
+ `Worktree \`${worktreeName}\` already exists at \`${existingWorktreePath}\``,
333
+ )
334
+ return
335
+ }
336
+
337
+ // Store pending worktree in database for this existing thread
338
+ createPendingWorktree({
339
+ threadId: thread.id,
340
+ worktreeName,
341
+ projectDirectory,
342
+ })
343
+
344
+ // Send status message in thread
345
+ const statusMessage = await thread.send({
346
+ content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...`,
347
+ flags: SILENT_MESSAGE_FLAGS,
348
+ })
349
+
350
+ await command.editReply(`Creating worktree \`${worktreeName}\` for this thread...`)
351
+
352
+ // Create worktree in background
353
+ createWorktreeInBackground({
354
+ thread,
355
+ starterMessage: statusMessage,
356
+ worktreeName,
357
+ projectDirectory,
358
+ clientV2,
359
+ }).catch((e) => {
360
+ logger.error('[NEW-WORKTREE] Background error:', e)
361
+ })
362
+ }
package/src/database.ts CHANGED
@@ -105,6 +105,8 @@ export function getDatabase(): Database.Database {
105
105
  `)
106
106
 
107
107
  runModelMigrations(db)
108
+ runWorktreeSettingsMigrations(db)
109
+ runVerbosityMigrations(db)
108
110
  }
109
111
 
110
112
  return db
@@ -338,6 +340,112 @@ export function deleteThreadWorktree(threadId: string): void {
338
340
  db.prepare('DELETE FROM thread_worktrees WHERE thread_id = ?').run(threadId)
339
341
  }
340
342
 
343
+ /**
344
+ * Run migrations for channel worktree settings table.
345
+ * Called on startup. Allows per-channel opt-in for automatic worktree creation.
346
+ */
347
+ export function runWorktreeSettingsMigrations(database?: Database.Database): void {
348
+ const targetDb = database || getDatabase()
349
+
350
+ targetDb.exec(`
351
+ CREATE TABLE IF NOT EXISTS channel_worktrees (
352
+ channel_id TEXT PRIMARY KEY,
353
+ enabled INTEGER NOT NULL DEFAULT 0,
354
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
355
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
356
+ )
357
+ `)
358
+
359
+ dbLogger.log('Channel worktree settings migrations complete')
360
+ }
361
+
362
+ // Verbosity levels for controlling output detail
363
+ export type VerbosityLevel = 'tools-and-text' | 'text-only'
364
+
365
+ export function runVerbosityMigrations(database?: Database.Database): void {
366
+ const targetDb = database || getDatabase()
367
+
368
+ targetDb.exec(`
369
+ CREATE TABLE IF NOT EXISTS channel_verbosity (
370
+ channel_id TEXT PRIMARY KEY,
371
+ verbosity TEXT NOT NULL DEFAULT 'tools-and-text',
372
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
373
+ )
374
+ `)
375
+
376
+ dbLogger.log('Channel verbosity settings migrations complete')
377
+ }
378
+
379
+ /**
380
+ * Get the verbosity setting for a channel.
381
+ * @returns 'tools-and-text' (default) or 'text-only'
382
+ */
383
+ export function getChannelVerbosity(channelId: string): VerbosityLevel {
384
+ const db = getDatabase()
385
+ const row = db
386
+ .prepare('SELECT verbosity FROM channel_verbosity WHERE channel_id = ?')
387
+ .get(channelId) as { verbosity: string } | undefined
388
+ return (row?.verbosity as VerbosityLevel) || 'tools-and-text'
389
+ }
390
+
391
+ /**
392
+ * Set the verbosity setting for a channel.
393
+ */
394
+ export function setChannelVerbosity(channelId: string, verbosity: VerbosityLevel): void {
395
+ const db = getDatabase()
396
+ db.prepare(
397
+ `INSERT INTO channel_verbosity (channel_id, verbosity, updated_at)
398
+ VALUES (?, ?, CURRENT_TIMESTAMP)
399
+ ON CONFLICT(channel_id) DO UPDATE SET verbosity = ?, updated_at = CURRENT_TIMESTAMP`,
400
+ ).run(channelId, verbosity, verbosity)
401
+ }
402
+
403
+ /**
404
+ * Check if automatic worktree creation is enabled for a channel.
405
+ */
406
+ export function getChannelWorktreesEnabled(channelId: string): boolean {
407
+ const db = getDatabase()
408
+ const row = db
409
+ .prepare('SELECT enabled FROM channel_worktrees WHERE channel_id = ?')
410
+ .get(channelId) as { enabled: number } | undefined
411
+ return row?.enabled === 1
412
+ }
413
+
414
+ /**
415
+ * Enable or disable automatic worktree creation for a channel.
416
+ */
417
+ export function setChannelWorktreesEnabled(channelId: string, enabled: boolean): void {
418
+ const db = getDatabase()
419
+ db.prepare(
420
+ `INSERT INTO channel_worktrees (channel_id, enabled, updated_at)
421
+ VALUES (?, ?, CURRENT_TIMESTAMP)
422
+ ON CONFLICT(channel_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`,
423
+ ).run(channelId, enabled ? 1 : 0, enabled ? 1 : 0)
424
+ }
425
+
426
+ /**
427
+ * Get the directory and app_id for a channel from the database.
428
+ * This is the single source of truth for channel-project mappings.
429
+ */
430
+ export function getChannelDirectory(channelId: string): {
431
+ directory: string
432
+ appId: string | null
433
+ } | undefined {
434
+ const db = getDatabase()
435
+ const row = db
436
+ .prepare('SELECT directory, app_id FROM channel_directories WHERE channel_id = ?')
437
+ .get(channelId) as { directory: string; app_id: string | null } | undefined
438
+
439
+ if (!row) {
440
+ return undefined
441
+ }
442
+
443
+ return {
444
+ directory: row.directory,
445
+ appId: row.app_id,
446
+ }
447
+ }
448
+
341
449
  export function closeDatabase(): void {
342
450
  if (db) {
343
451
  db.close()