kimaki 0.4.42 → 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 +236 -30
- 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 +63 -27
- package/dist/system-message.js +44 -5
- package/dist/voice-handler.js +1 -0
- package/dist/worktree-utils.js +50 -0
- package/package.json +2 -2
- package/src/cli.ts +287 -35
- 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 +71 -31
- package/src/system-message.ts +56 -4
- package/src/voice-handler.ts +1 -0
- package/src/worktree-utils.ts +78 -0
|
@@ -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
|
package/src/session-handler.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getSessionAgent,
|
|
14
14
|
getChannelAgent,
|
|
15
15
|
setSessionAgent,
|
|
16
|
+
getThreadWorktree,
|
|
16
17
|
} from './database.js'
|
|
17
18
|
import {
|
|
18
19
|
initializeOpencodeForDirectory,
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
} from './opencode.js'
|
|
22
23
|
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js'
|
|
23
24
|
import { formatPart } from './message-formatting.js'
|
|
24
|
-
import { getOpencodeSystemMessage } from './system-message.js'
|
|
25
|
+
import { getOpencodeSystemMessage, type WorktreeInfo } from './system-message.js'
|
|
25
26
|
import { createLogger } from './logger.js'
|
|
26
27
|
import { isAbortError } from './utils.js'
|
|
27
28
|
import {
|
|
@@ -38,9 +39,12 @@ const discordLogger = createLogger('DISCORD')
|
|
|
38
39
|
|
|
39
40
|
export const abortControllers = new Map<string, AbortController>()
|
|
40
41
|
|
|
42
|
+
// Track multiple pending permissions per thread (keyed by permission ID)
|
|
43
|
+
// OpenCode handles blocking/sequencing - we just need to track all pending permissions
|
|
44
|
+
// to avoid duplicates and properly clean up on auto-reject
|
|
41
45
|
export const pendingPermissions = new Map<
|
|
42
|
-
string,
|
|
43
|
-
{ permission: PermissionRequest; messageId: string; directory: string; contextHash: string }
|
|
46
|
+
string, // threadId
|
|
47
|
+
Map<string, { permission: PermissionRequest; messageId: string; directory: string; contextHash: string }> // permissionId -> data
|
|
44
48
|
>()
|
|
45
49
|
|
|
46
50
|
export type QueuedMessage = {
|
|
@@ -246,30 +250,34 @@ export async function handleOpencodeSession({
|
|
|
246
250
|
existingController.abort(new Error('New request started'))
|
|
247
251
|
}
|
|
248
252
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
253
|
+
// Auto-reject ALL pending permissions for this thread
|
|
254
|
+
const threadPermissions = pendingPermissions.get(thread.id)
|
|
255
|
+
if (threadPermissions && threadPermissions.size > 0) {
|
|
256
|
+
const clientV2 = getOpencodeClientV2(directory)
|
|
257
|
+
let rejectedCount = 0
|
|
258
|
+
for (const [permId, pendingPerm] of threadPermissions) {
|
|
259
|
+
try {
|
|
260
|
+
sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`)
|
|
261
|
+
if (clientV2) {
|
|
262
|
+
await clientV2.permission.reply({
|
|
263
|
+
requestID: permId,
|
|
264
|
+
reply: 'reject',
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
cleanupPermissionContext(pendingPerm.contextHash)
|
|
268
|
+
rejectedCount++
|
|
269
|
+
} catch (e) {
|
|
270
|
+
sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, e)
|
|
271
|
+
cleanupPermissionContext(pendingPerm.contextHash)
|
|
261
272
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
273
|
+
}
|
|
274
|
+
pendingPermissions.delete(thread.id)
|
|
275
|
+
if (rejectedCount > 0) {
|
|
276
|
+
const plural = rejectedCount > 1 ? 's' : ''
|
|
265
277
|
await sendThreadMessage(
|
|
266
278
|
thread,
|
|
267
|
-
`⚠️
|
|
279
|
+
`⚠️ ${rejectedCount} pending permission request${plural} auto-rejected due to new message`,
|
|
268
280
|
)
|
|
269
|
-
} catch (e) {
|
|
270
|
-
sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e)
|
|
271
|
-
cleanupPermissionContext(pendingPerm.contextHash)
|
|
272
|
-
pendingPermissions.delete(thread.id)
|
|
273
281
|
}
|
|
274
282
|
}
|
|
275
283
|
|
|
@@ -536,7 +544,7 @@ export async function handleOpencodeSession({
|
|
|
536
544
|
const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
|
|
537
545
|
(ctx) => ctx.thread.id === thread.id,
|
|
538
546
|
)
|
|
539
|
-
const hasPendingPermission = pendingPermissions.
|
|
547
|
+
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
540
548
|
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
541
549
|
stopTyping = startTyping()
|
|
542
550
|
}
|
|
@@ -612,7 +620,7 @@ export async function handleOpencodeSession({
|
|
|
612
620
|
const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
|
|
613
621
|
(ctx) => ctx.thread.id === thread.id,
|
|
614
622
|
)
|
|
615
|
-
const hasPendingPermission = pendingPermissions.
|
|
623
|
+
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
616
624
|
if (hasPendingQuestion || hasPendingPermission) return
|
|
617
625
|
stopTyping = startTyping()
|
|
618
626
|
}, 300)
|
|
@@ -650,6 +658,15 @@ export async function handleOpencodeSession({
|
|
|
650
658
|
continue
|
|
651
659
|
}
|
|
652
660
|
|
|
661
|
+
// Skip if this exact permission ID is already pending (dedupe)
|
|
662
|
+
const threadPermissions = pendingPermissions.get(thread.id)
|
|
663
|
+
if (threadPermissions?.has(permission.id)) {
|
|
664
|
+
sessionLogger.log(
|
|
665
|
+
`[PERMISSION] Skipping duplicate permission ${permission.id} (already pending)`,
|
|
666
|
+
)
|
|
667
|
+
continue
|
|
668
|
+
}
|
|
669
|
+
|
|
653
670
|
sessionLogger.log(
|
|
654
671
|
`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
|
|
655
672
|
)
|
|
@@ -667,7 +684,11 @@ export async function handleOpencodeSession({
|
|
|
667
684
|
directory,
|
|
668
685
|
})
|
|
669
686
|
|
|
670
|
-
|
|
687
|
+
// Track permission in nested map (threadId -> permissionId -> data)
|
|
688
|
+
if (!pendingPermissions.has(thread.id)) {
|
|
689
|
+
pendingPermissions.set(thread.id, new Map())
|
|
690
|
+
}
|
|
691
|
+
pendingPermissions.get(thread.id)!.set(permission.id, {
|
|
671
692
|
permission,
|
|
672
693
|
messageId,
|
|
673
694
|
directory,
|
|
@@ -681,10 +702,18 @@ export async function handleOpencodeSession({
|
|
|
681
702
|
|
|
682
703
|
sessionLogger.log(`Permission ${requestID} replied with: ${reply}`)
|
|
683
704
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
705
|
+
// Clean up the specific permission from nested map
|
|
706
|
+
const threadPermissions = pendingPermissions.get(thread.id)
|
|
707
|
+
if (threadPermissions) {
|
|
708
|
+
const pending = threadPermissions.get(requestID)
|
|
709
|
+
if (pending) {
|
|
710
|
+
cleanupPermissionContext(pending.contextHash)
|
|
711
|
+
threadPermissions.delete(requestID)
|
|
712
|
+
// Remove thread entry if no more pending permissions
|
|
713
|
+
if (threadPermissions.size === 0) {
|
|
714
|
+
pendingPermissions.delete(thread.id)
|
|
715
|
+
}
|
|
716
|
+
}
|
|
688
717
|
}
|
|
689
718
|
} else if (event.type === 'question.asked') {
|
|
690
719
|
const questionRequest = event.properties
|
|
@@ -951,6 +980,17 @@ export async function handleOpencodeSession({
|
|
|
951
980
|
return { providerID, modelID }
|
|
952
981
|
})()
|
|
953
982
|
|
|
983
|
+
// Get worktree info if this thread is in a worktree
|
|
984
|
+
const worktreeInfo = getThreadWorktree(thread.id)
|
|
985
|
+
const worktree: WorktreeInfo | undefined =
|
|
986
|
+
worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
987
|
+
? {
|
|
988
|
+
worktreeDirectory: worktreeInfo.worktree_directory,
|
|
989
|
+
branch: worktreeInfo.worktree_name,
|
|
990
|
+
mainRepoDirectory: worktreeInfo.project_directory,
|
|
991
|
+
}
|
|
992
|
+
: undefined
|
|
993
|
+
|
|
954
994
|
// Use session.command API for slash commands, session.prompt for regular messages
|
|
955
995
|
const response = command
|
|
956
996
|
? await getClient().session.command({
|
|
@@ -966,7 +1006,7 @@ export async function handleOpencodeSession({
|
|
|
966
1006
|
path: { id: session.id },
|
|
967
1007
|
body: {
|
|
968
1008
|
parts,
|
|
969
|
-
system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
|
|
1009
|
+
system: getOpencodeSystemMessage({ sessionId: session.id, channelId, worktree }),
|
|
970
1010
|
model: modelParam,
|
|
971
1011
|
agent: agentPreference,
|
|
972
1012
|
},
|