kimaki 0.4.76 → 0.4.78

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 (162) hide show
  1. package/dist/adapter-rest-boundary.test.js +34 -0
  2. package/dist/agent-model.e2e.test.js +2 -20
  3. package/dist/cli.js +50 -13
  4. package/dist/commands/channel-ref.js +16 -0
  5. package/dist/commands/diff.js +20 -85
  6. package/dist/commands/merge-worktree.js +5 -17
  7. package/dist/commands/new-worktree.js +5 -9
  8. package/dist/commands/permissions.js +77 -11
  9. package/dist/commands/resume.js +5 -9
  10. package/dist/commands/screenshare.js +295 -0
  11. package/dist/commands/session.js +6 -17
  12. package/dist/critique-utils.js +95 -0
  13. package/dist/diff-patch-plugin.js +314 -0
  14. package/dist/discord-bot.js +19 -14
  15. package/dist/discord-js-import-boundary.test.js +62 -0
  16. package/dist/discord-utils.js +44 -0
  17. package/dist/event-stream-real-capture.e2e.test.js +2 -20
  18. package/dist/gateway-proxy.e2e.test.js +2 -5
  19. package/dist/generated/cloudflare/browser.js +17 -0
  20. package/dist/generated/cloudflare/client.js +34 -0
  21. package/dist/generated/cloudflare/commonInputTypes.js +10 -0
  22. package/dist/generated/cloudflare/enums.js +48 -0
  23. package/dist/generated/cloudflare/internal/class.js +47 -0
  24. package/dist/generated/cloudflare/internal/prismaNamespace.js +252 -0
  25. package/dist/generated/cloudflare/internal/prismaNamespaceBrowser.js +222 -0
  26. package/dist/generated/cloudflare/internal/query_compiler_fast_bg.js +135 -0
  27. package/dist/generated/cloudflare/models/bot_api_keys.js +1 -0
  28. package/dist/generated/cloudflare/models/bot_tokens.js +1 -0
  29. package/dist/generated/cloudflare/models/channel_agents.js +1 -0
  30. package/dist/generated/cloudflare/models/channel_directories.js +1 -0
  31. package/dist/generated/cloudflare/models/channel_mention_mode.js +1 -0
  32. package/dist/generated/cloudflare/models/channel_models.js +1 -0
  33. package/dist/generated/cloudflare/models/channel_verbosity.js +1 -0
  34. package/dist/generated/cloudflare/models/channel_worktrees.js +1 -0
  35. package/dist/generated/cloudflare/models/forum_sync_configs.js +1 -0
  36. package/dist/generated/cloudflare/models/global_models.js +1 -0
  37. package/dist/generated/cloudflare/models/ipc_requests.js +1 -0
  38. package/dist/generated/cloudflare/models/part_messages.js +1 -0
  39. package/dist/generated/cloudflare/models/scheduled_tasks.js +1 -0
  40. package/dist/generated/cloudflare/models/session_agents.js +1 -0
  41. package/dist/generated/cloudflare/models/session_events.js +1 -0
  42. package/dist/generated/cloudflare/models/session_models.js +1 -0
  43. package/dist/generated/cloudflare/models/session_start_sources.js +1 -0
  44. package/dist/generated/cloudflare/models/thread_sessions.js +1 -0
  45. package/dist/generated/cloudflare/models/thread_worktrees.js +1 -0
  46. package/dist/generated/cloudflare/models.js +1 -0
  47. package/dist/generated/node/browser.js +17 -0
  48. package/dist/generated/node/client.js +37 -0
  49. package/dist/generated/node/commonInputTypes.js +10 -0
  50. package/dist/generated/node/enums.js +48 -0
  51. package/dist/generated/node/internal/class.js +49 -0
  52. package/dist/generated/node/internal/prismaNamespace.js +252 -0
  53. package/dist/generated/node/internal/prismaNamespaceBrowser.js +222 -0
  54. package/dist/generated/node/models/bot_api_keys.js +1 -0
  55. package/dist/generated/node/models/bot_tokens.js +1 -0
  56. package/dist/generated/node/models/channel_agents.js +1 -0
  57. package/dist/generated/node/models/channel_directories.js +1 -0
  58. package/dist/generated/node/models/channel_mention_mode.js +1 -0
  59. package/dist/generated/node/models/channel_models.js +1 -0
  60. package/dist/generated/node/models/channel_verbosity.js +1 -0
  61. package/dist/generated/node/models/channel_worktrees.js +1 -0
  62. package/dist/generated/node/models/forum_sync_configs.js +1 -0
  63. package/dist/generated/node/models/global_models.js +1 -0
  64. package/dist/generated/node/models/ipc_requests.js +1 -0
  65. package/dist/generated/node/models/part_messages.js +1 -0
  66. package/dist/generated/node/models/scheduled_tasks.js +1 -0
  67. package/dist/generated/node/models/session_agents.js +1 -0
  68. package/dist/generated/node/models/session_events.js +1 -0
  69. package/dist/generated/node/models/session_models.js +1 -0
  70. package/dist/generated/node/models/session_start_sources.js +1 -0
  71. package/dist/generated/node/models/thread_sessions.js +1 -0
  72. package/dist/generated/node/models/thread_worktrees.js +1 -0
  73. package/dist/generated/node/models.js +1 -0
  74. package/dist/interaction-handler.js +10 -0
  75. package/dist/kimaki-digital-twin.e2e.test.js +2 -20
  76. package/dist/message-flags-boundary.test.js +54 -0
  77. package/dist/message-formatting.js +3 -62
  78. package/dist/onboarding-tutorial-plugin.js +1 -1
  79. package/dist/opencode-command.js +129 -0
  80. package/dist/opencode-command.test.js +48 -0
  81. package/dist/opencode-interrupt-plugin.js +19 -1
  82. package/dist/opencode-interrupt-plugin.test.js +0 -5
  83. package/dist/opencode-plugin-loading.e2e.test.js +9 -20
  84. package/dist/opencode-plugin.js +4 -4
  85. package/dist/opencode.js +150 -27
  86. package/dist/patch-text-parser.js +97 -0
  87. package/dist/platform/components-v2.js +20 -0
  88. package/dist/platform/discord-adapter.js +1440 -0
  89. package/dist/platform/discord-routes.js +31 -0
  90. package/dist/platform/message-flags.js +8 -0
  91. package/dist/platform/platform-value.js +41 -0
  92. package/dist/platform/slack-adapter.js +872 -0
  93. package/dist/platform/slack-markdown.js +169 -0
  94. package/dist/platform/types.js +4 -0
  95. package/dist/queue-advanced-e2e-setup.js +265 -0
  96. package/dist/queue-advanced-footer.e2e.test.js +173 -0
  97. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  98. package/dist/queue-advanced-permissions-typing.e2e.test.js +73 -1
  99. package/dist/runtime-lifecycle.e2e.test.js +2 -20
  100. package/dist/session-handler/event-stream-state.js +5 -0
  101. package/dist/session-handler/event-stream-state.test.js +6 -2
  102. package/dist/session-handler/thread-session-runtime.js +32 -2
  103. package/dist/system-message.js +26 -23
  104. package/dist/test-utils.js +16 -0
  105. package/dist/thread-message-queue.e2e.test.js +2 -20
  106. package/dist/utils.js +3 -1
  107. package/dist/voice-message.e2e.test.js +2 -20
  108. package/dist/voice.js +122 -9
  109. package/dist/voice.test.js +17 -2
  110. package/dist/websockify.js +69 -0
  111. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  112. package/package.json +4 -2
  113. package/skills/critique/SKILL.md +17 -0
  114. package/skills/egaki/SKILL.md +35 -0
  115. package/skills/event-sourcing-state/SKILL.md +252 -0
  116. package/skills/goke/SKILL.md +1 -0
  117. package/skills/npm-package/SKILL.md +21 -2
  118. package/skills/playwriter/SKILL.md +1 -1
  119. package/skills/x-articles/SKILL.md +554 -0
  120. package/src/agent-model.e2e.test.ts +4 -19
  121. package/src/cli.ts +60 -13
  122. package/src/commands/diff.ts +25 -99
  123. package/src/commands/merge-worktree.ts +5 -21
  124. package/src/commands/new-worktree.ts +5 -11
  125. package/src/commands/permissions.ts +100 -15
  126. package/src/commands/resume.ts +5 -12
  127. package/src/commands/screenshare.ts +354 -0
  128. package/src/commands/session.ts +6 -23
  129. package/src/critique-utils.ts +139 -0
  130. package/src/discord-bot.ts +20 -15
  131. package/src/discord-utils.ts +53 -0
  132. package/src/event-stream-real-capture.e2e.test.ts +4 -20
  133. package/src/gateway-proxy.e2e.test.ts +2 -5
  134. package/src/interaction-handler.ts +15 -0
  135. package/src/kimaki-digital-twin.e2e.test.ts +2 -21
  136. package/src/message-formatting.ts +3 -68
  137. package/src/onboarding-tutorial-plugin.ts +1 -1
  138. package/src/opencode-command.test.ts +70 -0
  139. package/src/opencode-command.ts +188 -0
  140. package/src/opencode-interrupt-plugin.test.ts +0 -5
  141. package/src/opencode-interrupt-plugin.ts +34 -1
  142. package/src/opencode-plugin-loading.e2e.test.ts +25 -35
  143. package/src/opencode-plugin.ts +5 -4
  144. package/src/opencode.ts +199 -32
  145. package/src/patch-text-parser.ts +107 -0
  146. package/src/queue-advanced-e2e-setup.ts +273 -0
  147. package/src/queue-advanced-footer.e2e.test.ts +211 -0
  148. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  149. package/src/queue-advanced-permissions-typing.e2e.test.ts +92 -0
  150. package/src/runtime-lifecycle.e2e.test.ts +4 -19
  151. package/src/session-handler/event-stream-state.test.ts +6 -2
  152. package/src/session-handler/event-stream-state.ts +5 -0
  153. package/src/session-handler/thread-session-runtime.ts +45 -2
  154. package/src/system-message.ts +26 -23
  155. package/src/test-utils.ts +17 -0
  156. package/src/thread-message-queue.e2e.test.ts +2 -20
  157. package/src/utils.ts +3 -1
  158. package/src/voice-message.e2e.test.ts +3 -20
  159. package/src/voice.test.ts +26 -2
  160. package/src/voice.ts +147 -9
  161. package/src/websockify.ts +101 -0
  162. package/src/worktree-lifecycle.e2e.test.ts +391 -0
package/src/cli.ts CHANGED
@@ -64,6 +64,7 @@ import { WORKTREE_PREFIX } from './commands/merge-worktree.js'
64
64
  import type { ThreadStartMarker } from './system-message.js'
65
65
  import { sendWelcomeMessage } from './onboarding-welcome.js'
66
66
  import { buildOpencodeEventLogLine } from './session-handler/opencode-session-event-log.js'
67
+ import { selectResolvedCommand } from './opencode-command.js'
67
68
  import yaml from 'js-yaml'
68
69
  import type {
69
70
  OpencodeClient,
@@ -552,7 +553,11 @@ async function ensureCommandAvailable({
552
553
  env: process.env,
553
554
  }).then(
554
555
  (result) => {
555
- return result.stdout.trim()
556
+ const resolved = selectResolvedCommand({
557
+ output: result.stdout,
558
+ isWindows,
559
+ })
560
+ return resolved || ''
556
561
  },
557
562
  () => {
558
563
  return ''
@@ -1084,6 +1089,16 @@ async function registerCommands({
1084
1089
  .setDescription('List and manage MCP servers for this project')
1085
1090
  .setDMPermission(false)
1086
1091
  .toJSON(),
1092
+ new SlashCommandBuilder()
1093
+ .setName('screenshare')
1094
+ .setDescription('Start screen sharing via VNC tunnel (auto-stops after 1 hour)')
1095
+ .setDMPermission(false)
1096
+ .toJSON(),
1097
+ new SlashCommandBuilder()
1098
+ .setName('screenshare-stop')
1099
+ .setDescription('Stop screen sharing')
1100
+ .setDMPermission(false)
1101
+ .toJSON(),
1087
1102
  ]
1088
1103
 
1089
1104
  // Add user-defined commands with source-based suffixes (-cmd / -skill)
@@ -1702,7 +1717,7 @@ async function resolveCredentials({
1702
1717
  }
1703
1718
 
1704
1719
  // Poll until the user installs the bot in a Discord server.
1705
- // 600 attempts x 2s = 20 minutes timeout.
1720
+ // 100 attempts x 3s = 5 minutes timeout.
1706
1721
  const s = isInteractive ? spinner() : undefined
1707
1722
  s?.start('Waiting for a Discord server with the bot installed...')
1708
1723
 
@@ -1712,9 +1727,9 @@ async function resolveCredentials({
1712
1727
 
1713
1728
  let guildId: string | undefined
1714
1729
  let installerDiscordUserId: string | undefined
1715
- for (let attempt = 0; attempt < 600; attempt++) {
1730
+ for (let attempt = 0; attempt < 100; attempt++) {
1716
1731
  await new Promise((resolve) => {
1717
- setTimeout(resolve, 2000)
1732
+ setTimeout(resolve, 3000)
1718
1733
  })
1719
1734
 
1720
1735
  // Progressive hints for interactive users who may be stuck
@@ -1756,10 +1771,10 @@ async function resolveCredentials({
1756
1771
  if (isInteractive) {
1757
1772
  s?.stop('Authorization timed out')
1758
1773
  } else {
1759
- emitJsonEvent({ type: 'error', message: 'Authorization timed out after 20 minutes' })
1774
+ emitJsonEvent({ type: 'error', message: 'Authorization timed out after 5 minutes' })
1760
1775
  }
1761
1776
  cliLogger.error(
1762
- 'Bot authorization timed out after 20 minutes. Please try again.',
1777
+ 'Bot authorization timed out after 5 minutes. Please try again.',
1763
1778
  )
1764
1779
  process.exit(EXIT_NO_RESTART)
1765
1780
  }
@@ -2117,13 +2132,20 @@ async function run({
2117
2132
 
2118
2133
  // Create default kimaki channel + welcome message in each guild.
2119
2134
  // Runs after channel sync so existing channels are detected correctly.
2120
- await ensureDefaultChannelsWithWelcome({
2121
- guilds,
2122
- discordClient,
2123
- appId,
2124
- isGatewayMode,
2125
- installerDiscordUserId,
2126
- })
2135
+ try {
2136
+ await ensureDefaultChannelsWithWelcome({
2137
+ guilds,
2138
+ discordClient,
2139
+ appId,
2140
+ isGatewayMode,
2141
+ installerDiscordUserId,
2142
+ })
2143
+ } catch (error) {
2144
+ cliLogger.warn(
2145
+ 'Background default channel creation failed:',
2146
+ error instanceof Error ? error.message : String(error),
2147
+ )
2148
+ }
2127
2149
  })()
2128
2150
 
2129
2151
  // Background: OpenCode init + slash command registration (non-blocking)
@@ -4126,6 +4148,31 @@ cli
4126
4148
  },
4127
4149
  )
4128
4150
 
4151
+ cli
4152
+ .command(
4153
+ 'screenshare',
4154
+ 'Share your screen via VNC tunnel. Auto-stops after 1 hour. Runs until Ctrl+C. Use tmux to run in background.',
4155
+ )
4156
+ .action(async () => {
4157
+ const { startScreenshare } = await import(
4158
+ './commands/screenshare.js'
4159
+ )
4160
+ try {
4161
+ const session = await startScreenshare({
4162
+ sessionKey: 'cli',
4163
+ startedBy: 'cli',
4164
+ })
4165
+ cliLogger.log(`Screen sharing started: ${session.noVncUrl}`)
4166
+ cliLogger.log('Press Ctrl+C to stop')
4167
+ } catch (err) {
4168
+ cliLogger.error(
4169
+ 'Failed to start screen share:',
4170
+ err instanceof Error ? err.message : String(err),
4171
+ )
4172
+ process.exit(EXIT_NO_RESTART)
4173
+ }
4174
+ })
4175
+
4129
4176
  cli
4130
4177
  .command('sqlitedb', 'Show the location of the SQLite database file')
4131
4178
  .action(() => {
@@ -14,7 +14,7 @@ import {
14
14
  SILENT_MESSAGE_FLAGS,
15
15
  } from '../discord-utils.js'
16
16
  import { createLogger, LogPrefix } from '../logger.js'
17
- import { execAsync } from '../worktrees.js'
17
+ import { uploadGitDiffViaCritique } from '../critique-utils.js'
18
18
 
19
19
  const logger = createLogger(LogPrefix.DIFF)
20
20
 
@@ -63,103 +63,29 @@ export async function handleDiffCommand({
63
63
 
64
64
  await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
65
65
 
66
- try {
67
- const projectName = path.basename(workingDirectory)
68
- const title = `${projectName}: Discord /diff`
69
- const { stdout, stderr } = await execAsync(
70
- `bunx critique --web "${title}" --json`,
71
- {
72
- cwd: workingDirectory,
73
- timeout: 30000,
74
- },
75
- )
76
-
77
- // critique --json outputs JSON on the last line: {"url":"...","id":"..."} or {"error":"..."}
78
- const output = stdout || stderr
79
- const lines = output.trim().split('\n')
80
- const jsonLine = lines[lines.length - 1]
81
- if (!jsonLine) {
82
- await command.editReply({
83
- content: 'No changes to show',
84
- })
85
- return
86
- }
87
-
88
- let result: { url?: string; id?: string; error?: string }
89
- try {
90
- result = JSON.parse(jsonLine)
91
- } catch {
92
- // Fallback: try to find URL in output
93
- const urlMatch = output.match(/https?:\/\/critique\.work\/[^\s]+/)
94
- if (urlMatch) {
95
- await command.editReply({
96
- content: `[diff](${urlMatch[0]})`,
97
- })
98
- logger.log(`Diff shared: ${urlMatch[0]}`)
99
- return
100
- }
101
- await command.editReply({
102
- content: 'No changes to show',
103
- })
104
- return
105
- }
106
-
107
- if (result.error || !result.url || !result.id) {
108
- await command.editReply({
109
- content: result.error || 'No changes to show',
110
- })
111
- return
112
- }
113
-
114
- const imageUrl = `https://critique.work/og/${result.id}.png`
115
- const embed = new EmbedBuilder()
116
- .setTitle(title)
117
- .setURL(result.url)
118
- .setImage(imageUrl)
119
-
120
- await command.editReply({
121
- embeds: [embed],
122
- })
123
- logger.log(`Diff shared: ${result.url}`)
124
- } catch (error) {
125
- logger.error('[DIFF] Error:', error)
126
-
127
- // exec error includes stdout/stderr - try to parse JSON from it
128
- const execError = error as {
129
- stdout?: string
130
- stderr?: string
131
- message?: string
132
- }
133
- const output = execError.stdout || execError.stderr || ''
134
-
135
- // Check if critique output JSON even on error
136
- const lines = output.trim().split('\n')
137
- const jsonLine = lines[lines.length - 1]
138
- if (jsonLine) {
139
- try {
140
- const result = JSON.parse(jsonLine) as { error?: string }
141
- if (result.error) {
142
- await command.editReply({
143
- content: result.error,
144
- })
145
- return
146
- }
147
- } catch {
148
- // not JSON, continue to generic error
149
- }
150
- }
151
-
152
- // Check for common errors
153
- const message = execError.message || 'Unknown error'
154
- if (message.includes('command not found') || message.includes('ENOENT')) {
155
- await command.editReply({
156
- content: 'bunx/critique not available',
157
- })
158
- return
159
- }
160
-
161
- await command.editReply({
162
- content: `Failed to generate diff: ${message.slice(0, 200)}`,
163
- })
66
+ const projectName = path.basename(workingDirectory)
67
+ const title = `${projectName}: Discord /diff`
68
+ const result = await uploadGitDiffViaCritique({
69
+ title,
70
+ cwd: workingDirectory,
71
+ })
72
+
73
+ if (!result) {
74
+ await command.editReply({ content: 'No changes to show' })
75
+ return
164
76
  }
77
+
78
+ if (result.error || !result.url) {
79
+ await command.editReply({ content: result.error || 'No changes to show' })
80
+ return
81
+ }
82
+
83
+ const imageUrl = `https://critique.work/og/${result.id}.png`
84
+ const embed = new EmbedBuilder()
85
+ .setTitle(title)
86
+ .setURL(result.url)
87
+ .setImage(imageUrl)
88
+
89
+ await command.editReply({ embeds: [embed] })
90
+ logger.log(`Diff shared: ${result.url}`)
165
91
  }
@@ -15,7 +15,7 @@ import { mergeWorktree, listBranchesByLastCommit, validateBranchRef } from '../w
15
15
  import {
16
16
  sendThreadMessage,
17
17
  resolveWorkingDirectory,
18
- resolveTextChannel,
18
+ resolveProjectDirectoryFromAutocomplete,
19
19
  } from '../discord-utils.js'
20
20
  import {
21
21
  getOrCreateRuntime,
@@ -189,26 +189,10 @@ export async function handleMergeWorktreeAutocomplete({
189
189
  try {
190
190
  const focusedValue = interaction.options.getFocused()
191
191
 
192
- let projectDirectory: string | undefined
193
-
194
- // Try to get directory from worktree info (we're in a thread)
195
- if (interaction.channel?.isThread()) {
196
- const worktreeInfo = await getThreadWorktree(interaction.channel.id)
197
- if (worktreeInfo?.project_directory) {
198
- projectDirectory = worktreeInfo.project_directory
199
- }
200
- }
201
-
202
- // Fallback: resolve from parent channel
203
- if (!projectDirectory && interaction.channel) {
204
- const textChannel = await resolveTextChannel(
205
- interaction.channel as TextChannel | ThreadChannel | null,
206
- )
207
- if (textChannel) {
208
- const channelConfig = await getChannelDirectory(textChannel.id)
209
- projectDirectory = channelConfig?.directory
210
- }
211
- }
192
+ // interaction.channel can be null when the channel isn't cached
193
+ // (common with gateway-proxy). Use channelId which is always available
194
+ // from the raw interaction payload.
195
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction)
212
196
 
213
197
  if (!projectDirectory) {
214
198
  await interaction.respond([])
@@ -21,7 +21,7 @@ import {
21
21
  import {
22
22
  SILENT_MESSAGE_FLAGS,
23
23
  reactToThread,
24
- resolveTextChannel,
24
+ resolveProjectDirectoryFromAutocomplete,
25
25
  } from '../discord-utils.js'
26
26
  import { createLogger, LogPrefix } from '../logger.js'
27
27
  import { notifyError } from '../sentry.js'
@@ -452,16 +452,10 @@ export async function handleNewWorktreeAutocomplete({
452
452
  try {
453
453
  const focusedValue = interaction.options.getFocused()
454
454
 
455
- let projectDirectory: string | undefined
456
- if (interaction.channel) {
457
- const textChannel = await resolveTextChannel(
458
- interaction.channel as TextChannel | ThreadChannel | null,
459
- )
460
- if (textChannel) {
461
- const channelConfig = await getChannelDirectory(textChannel.id)
462
- projectDirectory = channelConfig?.directory
463
- }
464
- }
455
+ // interaction.channel can be null when the channel isn't cached
456
+ // (common with gateway-proxy). Use channelId which is always available
457
+ // from the raw interaction payload.
458
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction)
465
459
 
466
460
  if (!projectDirectory) {
467
461
  await interaction.respond([])
@@ -70,6 +70,7 @@ type PendingPermissionContext = {
70
70
  permissionDirectory: string
71
71
  thread: ThreadChannel
72
72
  contextHash: string
73
+ messageId?: string
73
74
  }
74
75
 
75
76
  // Store pending permission contexts by hash.
@@ -189,11 +190,107 @@ export async function showPermissionButtons({
189
190
  flags: NOTIFY_MESSAGE_FLAGS | MessageFlags.SuppressEmbeds,
190
191
  })
191
192
 
193
+ context.messageId = permissionMessage.id
194
+
192
195
  logger.log(`Showed permission buttons for ${permission.id}`)
193
196
 
194
197
  return { messageId: permissionMessage.id, contextHash }
195
198
  }
196
199
 
200
+ function updatePermissionMessage({
201
+ context,
202
+ status,
203
+ }: {
204
+ context: PendingPermissionContext
205
+ status: string
206
+ }): void {
207
+ if (!context.messageId) {
208
+ return
209
+ }
210
+ context.thread.messages
211
+ .fetch(context.messageId)
212
+ .then((message) => {
213
+ const patternStr = compactPermissionPatterns(context.permission.patterns).join(', ')
214
+ const externalDirLine =
215
+ context.permission.permission === 'external_directory'
216
+ ? 'Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n'
217
+ : ''
218
+ return message.edit({
219
+ content:
220
+ `⚠️ **Permission Required**\n` +
221
+ `**Type:** \`${context.permission.permission}\`\n` +
222
+ externalDirLine +
223
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
224
+ status,
225
+ components: [],
226
+ })
227
+ })
228
+ .catch((error) => {
229
+ logger.error('Failed to update permission message:', error)
230
+ })
231
+ }
232
+
233
+ export async function cancelPendingPermission(threadId: string): Promise<boolean> {
234
+ const contexts = Array.from(pendingPermissionContexts.values()).filter((context) => {
235
+ return context.thread.id === threadId
236
+ })
237
+
238
+ if (contexts.length === 0) {
239
+ return false
240
+ }
241
+
242
+ let cancelledCount = 0
243
+ for (const context of contexts) {
244
+ const pendingContext = takePendingPermissionContext(context.contextHash)
245
+ if (!pendingContext) {
246
+ continue
247
+ }
248
+
249
+ const client = getOpencodeClient(pendingContext.directory)
250
+ if (!client) {
251
+ pendingPermissionContexts.set(pendingContext.contextHash, pendingContext)
252
+ logger.error('Failed to dismiss pending permission: OpenCode server not found')
253
+ continue
254
+ }
255
+
256
+ const requestIds = pendingContext.requestIds.length > 0
257
+ ? pendingContext.requestIds
258
+ : [pendingContext.permission.id]
259
+
260
+ const result = await Promise.all(
261
+ requestIds.map((requestId) => {
262
+ return client.permission.reply({
263
+ requestID: requestId,
264
+ directory: pendingContext.permissionDirectory,
265
+ reply: 'reject',
266
+ })
267
+ }),
268
+ ).then(() => {
269
+ return 'ok' as const
270
+ }).catch((error) => {
271
+ pendingPermissionContexts.set(pendingContext.contextHash, pendingContext)
272
+ logger.error('Failed to dismiss pending permission:', error)
273
+ return 'error' as const
274
+ })
275
+
276
+ if (result === 'error') {
277
+ continue
278
+ }
279
+
280
+ updatePermissionMessage({
281
+ context: pendingContext,
282
+ status: '_Permission dismissed - user sent a new message._',
283
+ })
284
+ cancelledCount++
285
+ }
286
+
287
+ if (cancelledCount > 0) {
288
+ logger.log(`Dismissed ${cancelledCount} pending permission request(s) for thread ${threadId}`)
289
+ }
290
+
291
+ return cancelledCount > 0
292
+ }
293
+
197
294
  /**
198
295
  * Handle button click for permission.
199
296
  */
@@ -256,21 +353,9 @@ export async function handlePermissionButton(
256
353
  }
257
354
  })()
258
355
 
259
- const patternStr = compactPermissionPatterns(
260
- context.permission.patterns,
261
- ).join(', ')
262
- const externalDirLine =
263
- context.permission.permission === 'external_directory'
264
- ? `Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n`
265
- : ''
266
- await interaction.editReply({
267
- content:
268
- `⚠️ **Permission Required**\n` +
269
- `**Type:** \`${context.permission.permission}\`\n` +
270
- externalDirLine +
271
- (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
272
- resultText,
273
- components: [], // Remove the buttons
356
+ updatePermissionMessage({
357
+ context,
358
+ status: resultText,
274
359
  })
275
360
 
276
361
  logger.log(
@@ -15,7 +15,7 @@ import {
15
15
  getAllThreadSessionIds,
16
16
  } from '../database.js'
17
17
  import { initializeOpencodeForDirectory } from '../opencode.js'
18
- import { sendThreadMessage, resolveTextChannel } from '../discord-utils.js'
18
+ import { sendThreadMessage, resolveProjectDirectoryFromAutocomplete } from '../discord-utils.js'
19
19
  import { collectLastAssistantParts } from '../message-formatting.js'
20
20
  import { createLogger, LogPrefix } from '../logger.js'
21
21
  import * as errore from 'errore'
@@ -168,17 +168,10 @@ export async function handleResumeAutocomplete({
168
168
  }: AutocompleteContext): Promise<void> {
169
169
  const focusedValue = interaction.options.getFocused()
170
170
 
171
- let projectDirectory: string | undefined
172
-
173
- if (interaction.channel) {
174
- const textChannel = await resolveTextChannel(
175
- interaction.channel as TextChannel | ThreadChannel | null,
176
- )
177
- if (textChannel) {
178
- const channelConfig = await getChannelDirectory(textChannel.id)
179
- projectDirectory = channelConfig?.directory
180
- }
181
- }
171
+ // interaction.channel can be null when the channel isn't cached
172
+ // (common with gateway-proxy). Use channelId which is always available
173
+ // from the raw interaction payload.
174
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction)
182
175
 
183
176
  if (!projectDirectory) {
184
177
  await interaction.respond([])