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.
@@ -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
+ }
@@ -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 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 }),
96
+ const worktreeResult = await createWorktreeWithSubmodules({
97
+ clientV2,
98
+ directory: projectDirectory,
99
+ name: worktreeName,
114
100
  })
115
101
 
116
- if (errore.isError(worktreeResult)) {
102
+ if (worktreeResult instanceof Error) {
117
103
  const errorMsg = worktreeResult.message
118
- logger.error('[NEW-WORKTREE] Error:', worktreeResult.cause)
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: `worktree: ${worktreeName}`,
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()
@@ -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 { getDatabase, closeDatabase, getThreadWorktree } from './database.js'
6
- import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js'
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 threadName = hasVoice
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 the prompt
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
@@ -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
- const pendingPerm = pendingPermissions.get(thread.id)
250
- if (pendingPerm) {
251
- try {
252
- sessionLogger.log(
253
- `[PERMISSION] Auto-rejecting pending permission ${pendingPerm.permission.id} due to new message`,
254
- )
255
- const clientV2 = getOpencodeClientV2(directory)
256
- if (clientV2) {
257
- await clientV2.permission.reply({
258
- requestID: pendingPerm.permission.id,
259
- reply: 'reject',
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
- // Clean up both the pending permission and its dropdown context
263
- cleanupPermissionContext(pendingPerm.contextHash)
264
- pendingPermissions.delete(thread.id)
273
+ }
274
+ pendingPermissions.delete(thread.id)
275
+ if (rejectedCount > 0) {
276
+ const plural = rejectedCount > 1 ? 's' : ''
265
277
  await sendThreadMessage(
266
278
  thread,
267
- `⚠️ Previous permission request auto-rejected due to new message`,
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.has(thread.id)
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.has(thread.id)
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
- pendingPermissions.set(thread.id, {
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
- const pending = pendingPermissions.get(thread.id)
685
- if (pending && pending.permission.id === requestID) {
686
- cleanupPermissionContext(pending.contextHash)
687
- pendingPermissions.delete(thread.id)
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
  },