kimaki 0.4.76 → 0.4.77

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 (142) 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 +23 -13
  4. package/dist/commands/channel-ref.js +16 -0
  5. package/dist/commands/merge-worktree.js +5 -17
  6. package/dist/commands/new-worktree.js +5 -9
  7. package/dist/commands/permissions.js +77 -11
  8. package/dist/commands/resume.js +5 -9
  9. package/dist/commands/session.js +6 -17
  10. package/dist/discord-bot.js +18 -13
  11. package/dist/discord-js-import-boundary.test.js +62 -0
  12. package/dist/discord-utils.js +44 -0
  13. package/dist/event-stream-real-capture.e2e.test.js +2 -20
  14. package/dist/gateway-proxy.e2e.test.js +2 -5
  15. package/dist/generated/cloudflare/browser.js +17 -0
  16. package/dist/generated/cloudflare/client.js +34 -0
  17. package/dist/generated/cloudflare/commonInputTypes.js +10 -0
  18. package/dist/generated/cloudflare/enums.js +48 -0
  19. package/dist/generated/cloudflare/internal/class.js +47 -0
  20. package/dist/generated/cloudflare/internal/prismaNamespace.js +252 -0
  21. package/dist/generated/cloudflare/internal/prismaNamespaceBrowser.js +222 -0
  22. package/dist/generated/cloudflare/internal/query_compiler_fast_bg.js +135 -0
  23. package/dist/generated/cloudflare/models/bot_api_keys.js +1 -0
  24. package/dist/generated/cloudflare/models/bot_tokens.js +1 -0
  25. package/dist/generated/cloudflare/models/channel_agents.js +1 -0
  26. package/dist/generated/cloudflare/models/channel_directories.js +1 -0
  27. package/dist/generated/cloudflare/models/channel_mention_mode.js +1 -0
  28. package/dist/generated/cloudflare/models/channel_models.js +1 -0
  29. package/dist/generated/cloudflare/models/channel_verbosity.js +1 -0
  30. package/dist/generated/cloudflare/models/channel_worktrees.js +1 -0
  31. package/dist/generated/cloudflare/models/forum_sync_configs.js +1 -0
  32. package/dist/generated/cloudflare/models/global_models.js +1 -0
  33. package/dist/generated/cloudflare/models/ipc_requests.js +1 -0
  34. package/dist/generated/cloudflare/models/part_messages.js +1 -0
  35. package/dist/generated/cloudflare/models/scheduled_tasks.js +1 -0
  36. package/dist/generated/cloudflare/models/session_agents.js +1 -0
  37. package/dist/generated/cloudflare/models/session_events.js +1 -0
  38. package/dist/generated/cloudflare/models/session_models.js +1 -0
  39. package/dist/generated/cloudflare/models/session_start_sources.js +1 -0
  40. package/dist/generated/cloudflare/models/thread_sessions.js +1 -0
  41. package/dist/generated/cloudflare/models/thread_worktrees.js +1 -0
  42. package/dist/generated/cloudflare/models.js +1 -0
  43. package/dist/generated/node/browser.js +17 -0
  44. package/dist/generated/node/client.js +37 -0
  45. package/dist/generated/node/commonInputTypes.js +10 -0
  46. package/dist/generated/node/enums.js +48 -0
  47. package/dist/generated/node/internal/class.js +49 -0
  48. package/dist/generated/node/internal/prismaNamespace.js +252 -0
  49. package/dist/generated/node/internal/prismaNamespaceBrowser.js +222 -0
  50. package/dist/generated/node/models/bot_api_keys.js +1 -0
  51. package/dist/generated/node/models/bot_tokens.js +1 -0
  52. package/dist/generated/node/models/channel_agents.js +1 -0
  53. package/dist/generated/node/models/channel_directories.js +1 -0
  54. package/dist/generated/node/models/channel_mention_mode.js +1 -0
  55. package/dist/generated/node/models/channel_models.js +1 -0
  56. package/dist/generated/node/models/channel_verbosity.js +1 -0
  57. package/dist/generated/node/models/channel_worktrees.js +1 -0
  58. package/dist/generated/node/models/forum_sync_configs.js +1 -0
  59. package/dist/generated/node/models/global_models.js +1 -0
  60. package/dist/generated/node/models/ipc_requests.js +1 -0
  61. package/dist/generated/node/models/part_messages.js +1 -0
  62. package/dist/generated/node/models/scheduled_tasks.js +1 -0
  63. package/dist/generated/node/models/session_agents.js +1 -0
  64. package/dist/generated/node/models/session_events.js +1 -0
  65. package/dist/generated/node/models/session_models.js +1 -0
  66. package/dist/generated/node/models/session_start_sources.js +1 -0
  67. package/dist/generated/node/models/thread_sessions.js +1 -0
  68. package/dist/generated/node/models/thread_worktrees.js +1 -0
  69. package/dist/generated/node/models.js +1 -0
  70. package/dist/kimaki-digital-twin.e2e.test.js +2 -20
  71. package/dist/message-flags-boundary.test.js +54 -0
  72. package/dist/opencode-command.js +129 -0
  73. package/dist/opencode-command.test.js +48 -0
  74. package/dist/opencode-interrupt-plugin.js +19 -1
  75. package/dist/opencode-interrupt-plugin.test.js +0 -5
  76. package/dist/opencode-plugin-loading.e2e.test.js +9 -20
  77. package/dist/opencode.js +150 -27
  78. package/dist/platform/components-v2.js +20 -0
  79. package/dist/platform/discord-adapter.js +1440 -0
  80. package/dist/platform/discord-routes.js +31 -0
  81. package/dist/platform/message-flags.js +8 -0
  82. package/dist/platform/platform-value.js +41 -0
  83. package/dist/platform/slack-adapter.js +872 -0
  84. package/dist/platform/slack-markdown.js +169 -0
  85. package/dist/platform/types.js +4 -0
  86. package/dist/queue-advanced-e2e-setup.js +265 -0
  87. package/dist/queue-advanced-footer.e2e.test.js +173 -0
  88. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  89. package/dist/queue-advanced-permissions-typing.e2e.test.js +73 -1
  90. package/dist/runtime-lifecycle.e2e.test.js +2 -20
  91. package/dist/session-handler/event-stream-state.js +5 -0
  92. package/dist/session-handler/event-stream-state.test.js +6 -2
  93. package/dist/session-handler/thread-session-runtime.js +31 -1
  94. package/dist/system-message.js +26 -23
  95. package/dist/test-utils.js +16 -0
  96. package/dist/thread-message-queue.e2e.test.js +2 -20
  97. package/dist/utils.js +3 -1
  98. package/dist/voice-message.e2e.test.js +2 -20
  99. package/dist/voice.js +122 -9
  100. package/dist/voice.test.js +17 -2
  101. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  102. package/package.json +4 -4
  103. package/skills/critique/SKILL.md +17 -0
  104. package/skills/egaki/SKILL.md +35 -0
  105. package/skills/event-sourcing-state/SKILL.md +98 -0
  106. package/skills/goke/SKILL.md +1 -0
  107. package/skills/npm-package/SKILL.md +21 -2
  108. package/skills/x-articles/SKILL.md +554 -0
  109. package/src/agent-model.e2e.test.ts +4 -19
  110. package/src/cli.ts +25 -13
  111. package/src/commands/merge-worktree.ts +5 -21
  112. package/src/commands/new-worktree.ts +5 -11
  113. package/src/commands/permissions.ts +100 -15
  114. package/src/commands/resume.ts +5 -12
  115. package/src/commands/session.ts +6 -23
  116. package/src/discord-bot.ts +19 -14
  117. package/src/discord-utils.ts +53 -0
  118. package/src/event-stream-real-capture.e2e.test.ts +4 -20
  119. package/src/gateway-proxy.e2e.test.ts +2 -5
  120. package/src/kimaki-digital-twin.e2e.test.ts +2 -21
  121. package/src/opencode-command.test.ts +70 -0
  122. package/src/opencode-command.ts +188 -0
  123. package/src/opencode-interrupt-plugin.test.ts +0 -5
  124. package/src/opencode-interrupt-plugin.ts +34 -1
  125. package/src/opencode-plugin-loading.e2e.test.ts +25 -35
  126. package/src/opencode.ts +199 -32
  127. package/src/queue-advanced-e2e-setup.ts +273 -0
  128. package/src/queue-advanced-footer.e2e.test.ts +211 -0
  129. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  130. package/src/queue-advanced-permissions-typing.e2e.test.ts +92 -0
  131. package/src/runtime-lifecycle.e2e.test.ts +4 -19
  132. package/src/session-handler/event-stream-state.test.ts +6 -2
  133. package/src/session-handler/event-stream-state.ts +5 -0
  134. package/src/session-handler/thread-session-runtime.ts +43 -1
  135. package/src/system-message.ts +26 -23
  136. package/src/test-utils.ts +17 -0
  137. package/src/thread-message-queue.e2e.test.ts +2 -20
  138. package/src/utils.ts +3 -1
  139. package/src/voice-message.e2e.test.ts +3 -20
  140. package/src/voice.test.ts +26 -2
  141. package/src/voice.ts +147 -9
  142. package/src/worktree-lifecycle.e2e.test.ts +391 -0
@@ -0,0 +1,34 @@
1
+ // Guardrail test to keep REST helpers out of adapter-consumer runtime files.
2
+ // CLI/task-runner/discord-utils must use platform adapter interfaces instead of
3
+ // direct createDiscordRest/discordRoutes/discordApiUrl imports.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { describe, expect, test } from 'vitest';
7
+ const TARGET_FILES = [
8
+ 'src/cli.ts',
9
+ 'src/task-runner.ts',
10
+ 'src/discord-utils.ts',
11
+ ];
12
+ const PROJECT_ROOT = path.resolve(import.meta.dirname, '..');
13
+ describe('adapter rest boundary', () => {
14
+ test('forbids direct REST helper imports in migrated files', () => {
15
+ const violations = TARGET_FILES.flatMap((relativePath) => {
16
+ const filePath = path.join(PROJECT_ROOT, relativePath);
17
+ const content = fs.readFileSync(filePath, 'utf8');
18
+ const matches = [
19
+ /\bcreateDiscordRest\b/.test(content)
20
+ ? 'createDiscordRest'
21
+ : null,
22
+ /\bdiscordRoutes\b/.test(content) ? 'discordRoutes' : null,
23
+ /\bdiscordApiUrl\b/.test(content) ? 'discordApiUrl' : null,
24
+ ].filter((match) => {
25
+ return Boolean(match);
26
+ });
27
+ if (matches.length === 0) {
28
+ return [];
29
+ }
30
+ return [`${relativePath}: ${matches.join(', ')}`];
31
+ });
32
+ expect(violations).toMatchInlineSnapshot(`[]`);
33
+ });
34
+ });
@@ -10,7 +10,6 @@
10
10
  // Uses opencode-deterministic-provider (no real LLM calls).
11
11
  // Poll timeouts: 4s max, 100ms interval.
12
12
  import fs from 'node:fs';
13
- import net from 'node:net';
14
13
  import path from 'node:path';
15
14
  import url from 'node:url';
16
15
  import { describe, beforeAll, afterAll, test, expect, } from 'vitest';
@@ -24,7 +23,7 @@ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChann
24
23
  import { getPrisma } from './db.js';
25
24
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
26
25
  import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
27
- import { cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
26
+ import { chooseLockPort, cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
28
27
  import { buildQuickAgentCommandDescription } from './commands/agent.js';
29
28
  const TEST_USER_ID = '200000000000000920';
30
29
  const TEXT_CHANNEL_ID = '200000000000000921';
@@ -41,23 +40,6 @@ function createRunDirectories() {
41
40
  fs.mkdirSync(projectDirectory, { recursive: true });
42
41
  return { root, dataDir, projectDirectory };
43
42
  }
44
- function chooseLockPort() {
45
- return new Promise((resolve, reject) => {
46
- const server = net.createServer();
47
- server.listen(0, () => {
48
- const address = server.address();
49
- if (!address || typeof address === 'string') {
50
- server.close();
51
- reject(new Error('Failed to resolve lock port'));
52
- return;
53
- }
54
- const port = address.port;
55
- server.close(() => {
56
- resolve(port);
57
- });
58
- });
59
- });
60
- }
61
43
  function createDiscordJsClient({ restUrl }) {
62
44
  return new Client({
63
45
  intents: [
@@ -158,7 +140,7 @@ describe('agent model resolution', () => {
158
140
  beforeAll(async () => {
159
141
  testStartTime = Date.now();
160
142
  directories = createRunDirectories();
161
- const lockPort = await chooseLockPort();
143
+ const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID });
162
144
  process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
163
145
  setDataDir(directories.dataDir);
164
146
  previousDefaultVerbosity = store.getState().defaultVerbosity;
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { formatWorktreeName } from './commands/new-worktree.js';
13
13
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
14
14
  import { sendWelcomeMessage } from './onboarding-welcome.js';
15
15
  import { buildOpencodeEventLogLine } from './session-handler/opencode-session-event-log.js';
16
+ import { selectResolvedCommand } from './opencode-command.js';
16
17
  import yaml from 'js-yaml';
17
18
  import { Events, ChannelType, ActivityType, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
18
19
  import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl } from './discord-urls.js';
@@ -342,7 +343,11 @@ async function ensureCommandAvailable({ name, envPathKey, installUnix, installWi
342
343
  const foundInPath = await execAsync(`${whichCmd} ${name}`, {
343
344
  env: process.env,
344
345
  }).then((result) => {
345
- return result.stdout.trim();
346
+ const resolved = selectResolvedCommand({
347
+ output: result.stdout,
348
+ isWindows,
349
+ });
350
+ return resolved || '';
346
351
  }, () => {
347
352
  return '';
348
353
  });
@@ -1173,7 +1178,7 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
1173
1178
  emitJsonEvent({ type: 'install_url', url: oauthUrl });
1174
1179
  }
1175
1180
  // Poll until the user installs the bot in a Discord server.
1176
- // 600 attempts x 2s = 20 minutes timeout.
1181
+ // 100 attempts x 3s = 5 minutes timeout.
1177
1182
  const s = isInteractive ? spinner() : undefined;
1178
1183
  s?.start('Waiting for a Discord server with the bot installed...');
1179
1184
  const pollUrl = new URL('/api/onboarding/status', KIMAKI_WEBSITE_URL);
@@ -1181,9 +1186,9 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
1181
1186
  pollUrl.searchParams.set('secret', clientSecret);
1182
1187
  let guildId;
1183
1188
  let installerDiscordUserId;
1184
- for (let attempt = 0; attempt < 600; attempt++) {
1189
+ for (let attempt = 0; attempt < 100; attempt++) {
1185
1190
  await new Promise((resolve) => {
1186
- setTimeout(resolve, 2000);
1191
+ setTimeout(resolve, 3000);
1187
1192
  });
1188
1193
  // Progressive hints for interactive users who may be stuck
1189
1194
  if (isInteractive) {
@@ -1217,9 +1222,9 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
1217
1222
  s?.stop('Authorization timed out');
1218
1223
  }
1219
1224
  else {
1220
- emitJsonEvent({ type: 'error', message: 'Authorization timed out after 20 minutes' });
1225
+ emitJsonEvent({ type: 'error', message: 'Authorization timed out after 5 minutes' });
1221
1226
  }
1222
- cliLogger.error('Bot authorization timed out after 20 minutes. Please try again.');
1227
+ cliLogger.error('Bot authorization timed out after 5 minutes. Please try again.');
1223
1228
  process.exit(EXIT_NO_RESTART);
1224
1229
  }
1225
1230
  if (isInteractive) {
@@ -1507,13 +1512,18 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1507
1512
  }
1508
1513
  // Create default kimaki channel + welcome message in each guild.
1509
1514
  // Runs after channel sync so existing channels are detected correctly.
1510
- await ensureDefaultChannelsWithWelcome({
1511
- guilds,
1512
- discordClient,
1513
- appId,
1514
- isGatewayMode,
1515
- installerDiscordUserId,
1516
- });
1515
+ try {
1516
+ await ensureDefaultChannelsWithWelcome({
1517
+ guilds,
1518
+ discordClient,
1519
+ appId,
1520
+ isGatewayMode,
1521
+ installerDiscordUserId,
1522
+ });
1523
+ }
1524
+ catch (error) {
1525
+ cliLogger.warn('Background default channel creation failed:', error instanceof Error ? error.message : String(error));
1526
+ }
1517
1527
  })();
1518
1528
  // Background: OpenCode init + slash command registration (non-blocking)
1519
1529
  void backgroundInit({
@@ -0,0 +1,16 @@
1
+ // Helpers for working with normalized platform channel references in commands.
2
+ export function isThreadChannel(channel) {
3
+ return channel?.kind === 'thread';
4
+ }
5
+ export function isTextChannel(channel) {
6
+ return channel?.kind === 'text';
7
+ }
8
+ export function getRootChannelId(channel) {
9
+ if (!channel) {
10
+ return null;
11
+ }
12
+ if (channel.kind === 'thread') {
13
+ return channel.parentId || channel.id;
14
+ }
15
+ return channel.id;
16
+ }
@@ -6,7 +6,7 @@ import { getThreadWorktree, getThreadSession, getChannelDirectory, } from '../da
6
6
  import { createLogger, LogPrefix } from '../logger.js';
7
7
  import { notifyError } from '../sentry.js';
8
8
  import { mergeWorktree, listBranchesByLastCommit, validateBranchRef } from '../worktrees.js';
9
- import { sendThreadMessage, resolveWorkingDirectory, resolveTextChannel, } from '../discord-utils.js';
9
+ import { sendThreadMessage, resolveWorkingDirectory, resolveProjectDirectoryFromAutocomplete, } from '../discord-utils.js';
10
10
  import { getOrCreateRuntime, } from '../session-handler/thread-session-runtime.js';
11
11
  import { RebaseConflictError, DirtyWorktreeError } from '../errors.js';
12
12
  const logger = createLogger(LogPrefix.WORKTREE);
@@ -131,22 +131,10 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
131
131
  export async function handleMergeWorktreeAutocomplete({ interaction, }) {
132
132
  try {
133
133
  const focusedValue = interaction.options.getFocused();
134
- let projectDirectory;
135
- // Try to get directory from worktree info (we're in a thread)
136
- if (interaction.channel?.isThread()) {
137
- const worktreeInfo = await getThreadWorktree(interaction.channel.id);
138
- if (worktreeInfo?.project_directory) {
139
- projectDirectory = worktreeInfo.project_directory;
140
- }
141
- }
142
- // Fallback: resolve from parent channel
143
- if (!projectDirectory && interaction.channel) {
144
- const textChannel = await resolveTextChannel(interaction.channel);
145
- if (textChannel) {
146
- const channelConfig = await getChannelDirectory(textChannel.id);
147
- projectDirectory = channelConfig?.directory;
148
- }
149
- }
134
+ // interaction.channel can be null when the channel isn't cached
135
+ // (common with gateway-proxy). Use channelId which is always available
136
+ // from the raw interaction payload.
137
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction);
150
138
  if (!projectDirectory) {
151
139
  await interaction.respond([]);
152
140
  return;
@@ -4,7 +4,7 @@
4
4
  import { ChannelType, REST, } from 'discord.js';
5
5
  import fs from 'node:fs';
6
6
  import { createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelDirectory, getThreadWorktree, } from '../database.js';
7
- import { SILENT_MESSAGE_FLAGS, reactToThread, resolveTextChannel, } from '../discord-utils.js';
7
+ import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, } from '../discord-utils.js';
8
8
  import { createLogger, LogPrefix } from '../logger.js';
9
9
  import { notifyError } from '../sentry.js';
10
10
  import { createWorktreeWithSubmodules, execAsync, listBranchesByLastCommit, validateBranchRef, } from '../worktrees.js';
@@ -313,14 +313,10 @@ async function handleWorktreeInThread({ command, thread, }) {
313
313
  export async function handleNewWorktreeAutocomplete({ interaction, }) {
314
314
  try {
315
315
  const focusedValue = interaction.options.getFocused();
316
- let projectDirectory;
317
- if (interaction.channel) {
318
- const textChannel = await resolveTextChannel(interaction.channel);
319
- if (textChannel) {
320
- const channelConfig = await getChannelDirectory(textChannel.id);
321
- projectDirectory = channelConfig?.directory;
322
- }
323
- }
316
+ // interaction.channel can be null when the channel isn't cached
317
+ // (common with gateway-proxy). Use channelId which is always available
318
+ // from the raw interaction payload.
319
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction);
324
320
  if (!projectDirectory) {
325
321
  await interaction.respond([]);
326
322
  return;
@@ -118,9 +118,83 @@ export async function showPermissionButtons({ thread, permission, directory, per
118
118
  components: [actionRow],
119
119
  flags: NOTIFY_MESSAGE_FLAGS | MessageFlags.SuppressEmbeds,
120
120
  });
121
+ context.messageId = permissionMessage.id;
121
122
  logger.log(`Showed permission buttons for ${permission.id}`);
122
123
  return { messageId: permissionMessage.id, contextHash };
123
124
  }
125
+ function updatePermissionMessage({ context, status, }) {
126
+ if (!context.messageId) {
127
+ return;
128
+ }
129
+ context.thread.messages
130
+ .fetch(context.messageId)
131
+ .then((message) => {
132
+ const patternStr = compactPermissionPatterns(context.permission.patterns).join(', ');
133
+ const externalDirLine = context.permission.permission === 'external_directory'
134
+ ? 'Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n'
135
+ : '';
136
+ return message.edit({
137
+ content: `⚠️ **Permission Required**\n` +
138
+ `**Type:** \`${context.permission.permission}\`\n` +
139
+ externalDirLine +
140
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
141
+ status,
142
+ components: [],
143
+ });
144
+ })
145
+ .catch((error) => {
146
+ logger.error('Failed to update permission message:', error);
147
+ });
148
+ }
149
+ export async function cancelPendingPermission(threadId) {
150
+ const contexts = Array.from(pendingPermissionContexts.values()).filter((context) => {
151
+ return context.thread.id === threadId;
152
+ });
153
+ if (contexts.length === 0) {
154
+ return false;
155
+ }
156
+ let cancelledCount = 0;
157
+ for (const context of contexts) {
158
+ const pendingContext = takePendingPermissionContext(context.contextHash);
159
+ if (!pendingContext) {
160
+ continue;
161
+ }
162
+ const client = getOpencodeClient(pendingContext.directory);
163
+ if (!client) {
164
+ pendingPermissionContexts.set(pendingContext.contextHash, pendingContext);
165
+ logger.error('Failed to dismiss pending permission: OpenCode server not found');
166
+ continue;
167
+ }
168
+ const requestIds = pendingContext.requestIds.length > 0
169
+ ? pendingContext.requestIds
170
+ : [pendingContext.permission.id];
171
+ const result = await Promise.all(requestIds.map((requestId) => {
172
+ return client.permission.reply({
173
+ requestID: requestId,
174
+ directory: pendingContext.permissionDirectory,
175
+ reply: 'reject',
176
+ });
177
+ })).then(() => {
178
+ return 'ok';
179
+ }).catch((error) => {
180
+ pendingPermissionContexts.set(pendingContext.contextHash, pendingContext);
181
+ logger.error('Failed to dismiss pending permission:', error);
182
+ return 'error';
183
+ });
184
+ if (result === 'error') {
185
+ continue;
186
+ }
187
+ updatePermissionMessage({
188
+ context: pendingContext,
189
+ status: '_Permission dismissed - user sent a new message._',
190
+ });
191
+ cancelledCount++;
192
+ }
193
+ if (cancelledCount > 0) {
194
+ logger.log(`Dismissed ${cancelledCount} pending permission request(s) for thread ${threadId}`);
195
+ }
196
+ return cancelledCount > 0;
197
+ }
124
198
  /**
125
199
  * Handle button click for permission.
126
200
  */
@@ -166,17 +240,9 @@ export async function handlePermissionButton(interaction) {
166
240
  return '❌ Permission **rejected**';
167
241
  }
168
242
  })();
169
- const patternStr = compactPermissionPatterns(context.permission.patterns).join(', ');
170
- const externalDirLine = context.permission.permission === 'external_directory'
171
- ? `Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n`
172
- : '';
173
- await interaction.editReply({
174
- content: `⚠️ **Permission Required**\n` +
175
- `**Type:** \`${context.permission.permission}\`\n` +
176
- externalDirLine +
177
- (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
178
- resultText,
179
- components: [], // Remove the buttons
243
+ updatePermissionMessage({
244
+ context,
245
+ status: resultText,
180
246
  });
181
247
  logger.log(`Permission ${context.permission.id} ${response} (${requestIds.length} request(s))`);
182
248
  }
@@ -3,7 +3,7 @@ import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
3
3
  import fs from 'node:fs';
4
4
  import { getChannelDirectory, setThreadSession, setPartMessagesBatch, getAllThreadSessionIds, } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
- import { sendThreadMessage, resolveTextChannel } from '../discord-utils.js';
6
+ import { sendThreadMessage, resolveProjectDirectoryFromAutocomplete } from '../discord-utils.js';
7
7
  import { collectLastAssistantParts } from '../message-formatting.js';
8
8
  import { createLogger, LogPrefix } from '../logger.js';
9
9
  import * as errore from 'errore';
@@ -100,14 +100,10 @@ export async function handleResumeCommand({ command, }) {
100
100
  }
101
101
  export async function handleResumeAutocomplete({ interaction, }) {
102
102
  const focusedValue = interaction.options.getFocused();
103
- let projectDirectory;
104
- if (interaction.channel) {
105
- const textChannel = await resolveTextChannel(interaction.channel);
106
- if (textChannel) {
107
- const channelConfig = await getChannelDirectory(textChannel.id);
108
- projectDirectory = channelConfig?.directory;
109
- }
110
- }
103
+ // interaction.channel can be null when the channel isn't cached
104
+ // (common with gateway-proxy). Use channelId which is always available
105
+ // from the raw interaction payload.
106
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction);
111
107
  if (!projectDirectory) {
112
108
  await interaction.respond([]);
113
109
  return;
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { getChannelDirectory } from '../database.js';
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
7
- import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
7
+ import { SILENT_MESSAGE_FLAGS, resolveProjectDirectoryFromAutocomplete } from '../discord-utils.js';
8
8
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
9
9
  import { createLogger, LogPrefix } from '../logger.js';
10
10
  import * as errore from 'errore';
@@ -80,14 +80,10 @@ export async function handleSessionCommand({ command, appId, }) {
80
80
  }
81
81
  async function handleAgentAutocomplete({ interaction, }) {
82
82
  const focusedValue = interaction.options.getFocused();
83
- let projectDirectory;
84
- if (interaction.channel &&
85
- interaction.channel.type === ChannelType.GuildText) {
86
- const channelConfig = await getChannelDirectory(interaction.channel.id);
87
- if (channelConfig) {
88
- projectDirectory = channelConfig.directory;
89
- }
90
- }
83
+ // interaction.channel can be null when the channel isn't cached
84
+ // (common with gateway-proxy). Use channelId which is always available
85
+ // from the raw interaction payload.
86
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction);
91
87
  if (!projectDirectory) {
92
88
  await interaction.respond([]);
93
89
  return;
@@ -139,14 +135,7 @@ export async function handleSessionAutocomplete({ interaction, }) {
139
135
  .map((f) => f.trim())
140
136
  .filter((f) => f);
141
137
  const currentQuery = (parts[parts.length - 1] || '').trim();
142
- let projectDirectory;
143
- if (interaction.channel &&
144
- interaction.channel.type === ChannelType.GuildText) {
145
- const channelConfig = await getChannelDirectory(interaction.channel.id);
146
- if (channelConfig) {
147
- projectDirectory = channelConfig.directory;
148
- }
149
- }
138
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction);
150
139
  if (!projectDirectory) {
151
140
  await interaction.respond([]);
152
141
  return;
@@ -14,6 +14,7 @@ import { preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './
14
14
  import { cancelPendingActionButtons } from './commands/action-buttons.js';
15
15
  import { cancelPendingQuestion } from './commands/ask-question.js';
16
16
  import { cancelPendingFileUpload } from './commands/file-upload.js';
17
+ import { cancelPendingPermission } from './commands/permissions.js';
17
18
  import { cancelHtmlActionsForThread } from './html-actions.js';
18
19
  import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
19
20
  import { voiceConnections, cleanupVoiceConnection, registerVoiceStateHandler, } from './voice-handler.js';
@@ -330,19 +331,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
330
331
  if (isThread) {
331
332
  const thread = channel;
332
333
  discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
333
- // Cancel interactive UI when a real user sends a message.
334
- // If a question was pending and answered with the user's text,
335
- // early-return: the message was consumed as the question answer
336
- // and must NOT also be sent as a new prompt (causes abort loops).
337
- if (!message.author.bot && !isCliInjectedPrompt) {
338
- cancelPendingActionButtons(thread.id);
339
- cancelHtmlActionsForThread(thread.id);
340
- const questionResult = await cancelPendingQuestion(thread.id, message.content);
341
- void cancelPendingFileUpload(thread.id);
342
- if (questionResult === 'replied') {
343
- return;
344
- }
345
- }
346
334
  const parent = thread.parent;
347
335
  let projectDirectory;
348
336
  if (parent) {
@@ -424,6 +412,23 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
424
412
  channelId: parent?.id || undefined,
425
413
  appId: currentAppId,
426
414
  });
415
+ // Cancel interactive UI when a real user sends a message.
416
+ // If a question was pending and answered with the user's text,
417
+ // early-return: the message was consumed as the question answer
418
+ // and must NOT also be sent as a new prompt (causes abort loops).
419
+ if (!message.author.bot && !isCliInjectedPrompt) {
420
+ cancelPendingActionButtons(thread.id);
421
+ cancelHtmlActionsForThread(thread.id);
422
+ const dismissedPermission = await cancelPendingPermission(thread.id);
423
+ if (dismissedPermission) {
424
+ runtime.abortActiveRun('user sent a new message while permission was pending');
425
+ }
426
+ const questionResult = await cancelPendingQuestion(thread.id, message.content);
427
+ void cancelPendingFileUpload(thread.id);
428
+ if (questionResult === 'replied') {
429
+ return;
430
+ }
431
+ }
427
432
  // Expensive pre-processing (voice transcription, context fetch,
428
433
  // attachment download) runs inside the runtime's serialized
429
434
  // preprocess chain, preserving Discord arrival order without
@@ -0,0 +1,62 @@
1
+ // Guardrail test for adapter boundary imports.
2
+ // Runtime modules must not import discord.js directly outside the Discord adapter,
3
+ // forum-sync bridge, and voice handler.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { describe, expect, test } from 'vitest';
7
+ const SRC_DIR = path.resolve(import.meta.dirname);
8
+ function collectTsFiles(dir) {
9
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
10
+ return entries.flatMap((entry) => {
11
+ const fullPath = path.join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ return collectTsFiles(fullPath);
14
+ }
15
+ if (!entry.name.endsWith('.ts')) {
16
+ return [];
17
+ }
18
+ if (entry.name.endsWith('.test.ts')) {
19
+ return [];
20
+ }
21
+ if (entry.name.includes('e2e')) {
22
+ return [];
23
+ }
24
+ return [fullPath];
25
+ });
26
+ }
27
+ function toWorkspaceRelative(filePath) {
28
+ return path.relative(path.resolve(import.meta.dirname, '..'), filePath);
29
+ }
30
+ function isAllowedBoundaryFile(relativePath) {
31
+ if (relativePath === 'src/platform/discord-adapter.ts') {
32
+ return true;
33
+ }
34
+ if (relativePath === 'src/voice-handler.ts') {
35
+ return true;
36
+ }
37
+ if (relativePath.startsWith('src/forum-sync/')) {
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ describe('discord.js import boundary', () => {
43
+ test('does not import discord.js outside allowed modules', () => {
44
+ const violations = collectTsFiles(SRC_DIR)
45
+ .map((filePath) => {
46
+ return {
47
+ relativePath: toWorkspaceRelative(filePath),
48
+ content: fs.readFileSync(filePath, 'utf8'),
49
+ };
50
+ })
51
+ .filter(({ relativePath }) => {
52
+ return !isAllowedBoundaryFile(relativePath);
53
+ })
54
+ .filter(({ content }) => {
55
+ return /from\s+['"]discord\.js['"]/.test(content);
56
+ })
57
+ .map(({ relativePath }) => {
58
+ return relativePath;
59
+ });
60
+ expect(violations).toMatchInlineSnapshot(`[]`);
61
+ });
62
+ });
@@ -480,6 +480,50 @@ export async function getKimakiMetadata(textChannel) {
480
480
  projectDirectory: channelConfig.directory,
481
481
  };
482
482
  }
483
+ /**
484
+ * Resolve project directory from an autocomplete interaction.
485
+ * Uses interaction.channelId (always available from raw payload) instead of
486
+ * interaction.channel (cache-based getter, often null with gateway-proxy).
487
+ * Checks the channel ID directly in DB, then tries thread worktree lookup,
488
+ * then falls back to fetching the channel to resolve thread parent.
489
+ */
490
+ export async function resolveProjectDirectoryFromAutocomplete(interaction) {
491
+ const channelId = interaction.channelId;
492
+ // Direct channel lookup — works when the command is run from a project text channel
493
+ const channelConfig = await getChannelDirectory(channelId);
494
+ if (channelConfig) {
495
+ return channelConfig.directory;
496
+ }
497
+ // If we're in a thread, try worktree info first (has project_directory)
498
+ const worktreeInfo = await getThreadWorktree(channelId);
499
+ if (worktreeInfo?.project_directory) {
500
+ return worktreeInfo.project_directory;
501
+ }
502
+ // Thread fallback: resolve parent channel ID and look up its directory.
503
+ // Try cached channel first, then fetch if cache misses (gateway-proxy scenario).
504
+ const cachedParentId = interaction.channel?.isThread() ? interaction.channel.parentId : null;
505
+ if (cachedParentId) {
506
+ const parentConfig = await getChannelDirectory(cachedParentId);
507
+ if (parentConfig) {
508
+ return parentConfig.directory;
509
+ }
510
+ }
511
+ // Last resort: fetch the channel from Discord API to get parentId for threads
512
+ // when the channel isn't cached at all (common with gateway-proxy).
513
+ if (!cachedParentId) {
514
+ const fetched = await errore.tryAsync({
515
+ try: () => { return interaction.client.channels.fetch(channelId); },
516
+ catch: (e) => { return e; },
517
+ });
518
+ if (!(fetched instanceof Error) && fetched?.isThread() && fetched.parentId) {
519
+ const parentConfig = await getChannelDirectory(fetched.parentId);
520
+ if (parentConfig) {
521
+ return parentConfig.directory;
522
+ }
523
+ }
524
+ }
525
+ return undefined;
526
+ }
483
527
  /**
484
528
  * Resolve the working directory for a channel or thread.
485
529
  * Returns both the base project directory (for server init) and the working directory
@@ -2,7 +2,6 @@
2
2
  // Uses opencode-cached-provider + Gemini to record real tool/lifecycle streams
3
3
  // (task, interruption, permission, action buttons, and question flows).
4
4
  import fs from 'node:fs';
5
- import net from 'node:net';
6
5
  import path from 'node:path';
7
6
  import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
8
7
  import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
@@ -13,7 +12,7 @@ import { store } from './store.js';
13
12
  import { startDiscordBot } from './discord-bot.js';
14
13
  import { closeDatabase, getChannelVerbosity, initDatabase, setBotToken, setChannelDirectory, setChannelVerbosity, } from './database.js';
15
14
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
16
- import { cleanupTestSessions } from './test-utils.js';
15
+ import { chooseLockPort, cleanupTestSessions } from './test-utils.js';
17
16
  import { waitForBotMessageContaining, waitForBotReplyAfterUserMessage } from './test-utils.js';
18
17
  import { stopOpencodeServer } from './opencode.js';
19
18
  import { disposeRuntime, pendingPermissions } from './session-handler/thread-session-runtime.js';
@@ -46,23 +45,6 @@ function createRunDirectories() {
46
45
  fixtureOutputDir,
47
46
  };
48
47
  }
49
- function chooseLockPort() {
50
- return new Promise((resolve, reject) => {
51
- const server = net.createServer();
52
- server.listen(0, () => {
53
- const address = server.address();
54
- if (!address || typeof address === 'string') {
55
- server.close();
56
- reject(new Error('Failed to resolve lock port'));
57
- return;
58
- }
59
- const port = address.port;
60
- server.close(() => {
61
- resolve(port);
62
- });
63
- });
64
- });
65
- }
66
48
  function createDiscordJsClient({ restUrl }) {
67
49
  return new Client({
68
50
  intents: [
@@ -248,7 +230,7 @@ describe('real event stream capture fixtures (cached provider)', () => {
248
230
  }
249
231
  beforeAll(async () => {
250
232
  testStartTime = Date.now();
251
- lockPort = await chooseLockPort();
233
+ lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID });
252
234
  listJsonlFiles(directories.sessionEventsDir).forEach((fileName) => {
253
235
  fs.rmSync(path.join(directories.sessionEventsDir, fileName), {
254
236
  force: true,