kimaki 0.4.75 → 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 (154) 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 +75 -37
  4. package/dist/commands/ask-question.js +30 -17
  5. package/dist/commands/channel-ref.js +16 -0
  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/session.js +6 -17
  11. package/dist/discord-bot.js +19 -10
  12. package/dist/discord-js-import-boundary.test.js +62 -0
  13. package/dist/discord-utils.js +44 -0
  14. package/dist/event-stream-real-capture.e2e.test.js +2 -20
  15. package/dist/eventsource-parser.test.js +327 -0
  16. package/dist/gateway-proxy.e2e.test.js +2 -5
  17. package/dist/generated/cloudflare/browser.js +17 -0
  18. package/dist/generated/cloudflare/client.js +34 -0
  19. package/dist/generated/cloudflare/commonInputTypes.js +10 -0
  20. package/dist/generated/cloudflare/enums.js +48 -0
  21. package/dist/generated/cloudflare/internal/class.js +47 -0
  22. package/dist/generated/cloudflare/internal/prismaNamespace.js +252 -0
  23. package/dist/generated/cloudflare/internal/prismaNamespaceBrowser.js +222 -0
  24. package/dist/generated/cloudflare/internal/query_compiler_fast_bg.js +135 -0
  25. package/dist/generated/cloudflare/models/bot_api_keys.js +1 -0
  26. package/dist/generated/cloudflare/models/bot_tokens.js +1 -0
  27. package/dist/generated/cloudflare/models/channel_agents.js +1 -0
  28. package/dist/generated/cloudflare/models/channel_directories.js +1 -0
  29. package/dist/generated/cloudflare/models/channel_mention_mode.js +1 -0
  30. package/dist/generated/cloudflare/models/channel_models.js +1 -0
  31. package/dist/generated/cloudflare/models/channel_verbosity.js +1 -0
  32. package/dist/generated/cloudflare/models/channel_worktrees.js +1 -0
  33. package/dist/generated/cloudflare/models/forum_sync_configs.js +1 -0
  34. package/dist/generated/cloudflare/models/global_models.js +1 -0
  35. package/dist/generated/cloudflare/models/ipc_requests.js +1 -0
  36. package/dist/generated/cloudflare/models/part_messages.js +1 -0
  37. package/dist/generated/cloudflare/models/scheduled_tasks.js +1 -0
  38. package/dist/generated/cloudflare/models/session_agents.js +1 -0
  39. package/dist/generated/cloudflare/models/session_events.js +1 -0
  40. package/dist/generated/cloudflare/models/session_models.js +1 -0
  41. package/dist/generated/cloudflare/models/session_start_sources.js +1 -0
  42. package/dist/generated/cloudflare/models/thread_sessions.js +1 -0
  43. package/dist/generated/cloudflare/models/thread_worktrees.js +1 -0
  44. package/dist/generated/cloudflare/models.js +1 -0
  45. package/dist/generated/node/browser.js +17 -0
  46. package/dist/generated/node/client.js +37 -0
  47. package/dist/generated/node/commonInputTypes.js +10 -0
  48. package/dist/generated/node/enums.js +48 -0
  49. package/dist/generated/node/internal/class.js +49 -0
  50. package/dist/generated/node/internal/prismaNamespace.js +252 -0
  51. package/dist/generated/node/internal/prismaNamespaceBrowser.js +222 -0
  52. package/dist/generated/node/models/bot_api_keys.js +1 -0
  53. package/dist/generated/node/models/bot_tokens.js +1 -0
  54. package/dist/generated/node/models/channel_agents.js +1 -0
  55. package/dist/generated/node/models/channel_directories.js +1 -0
  56. package/dist/generated/node/models/channel_mention_mode.js +1 -0
  57. package/dist/generated/node/models/channel_models.js +1 -0
  58. package/dist/generated/node/models/channel_verbosity.js +1 -0
  59. package/dist/generated/node/models/channel_worktrees.js +1 -0
  60. package/dist/generated/node/models/forum_sync_configs.js +1 -0
  61. package/dist/generated/node/models/global_models.js +1 -0
  62. package/dist/generated/node/models/ipc_requests.js +1 -0
  63. package/dist/generated/node/models/part_messages.js +1 -0
  64. package/dist/generated/node/models/scheduled_tasks.js +1 -0
  65. package/dist/generated/node/models/session_agents.js +1 -0
  66. package/dist/generated/node/models/session_events.js +1 -0
  67. package/dist/generated/node/models/session_models.js +1 -0
  68. package/dist/generated/node/models/session_start_sources.js +1 -0
  69. package/dist/generated/node/models/thread_sessions.js +1 -0
  70. package/dist/generated/node/models/thread_worktrees.js +1 -0
  71. package/dist/generated/node/models.js +1 -0
  72. package/dist/kimaki-digital-twin.e2e.test.js +2 -20
  73. package/dist/message-flags-boundary.test.js +54 -0
  74. package/dist/message-preprocessing.js +4 -2
  75. package/dist/opencode-command.js +129 -0
  76. package/dist/opencode-command.test.js +48 -0
  77. package/dist/opencode-interrupt-plugin.js +19 -1
  78. package/dist/opencode-interrupt-plugin.test.js +0 -5
  79. package/dist/opencode-plugin-loading.e2e.test.js +9 -20
  80. package/dist/opencode.js +150 -27
  81. package/dist/platform/components-v2.js +20 -0
  82. package/dist/platform/discord-adapter.js +1440 -0
  83. package/dist/platform/discord-routes.js +31 -0
  84. package/dist/platform/message-flags.js +8 -0
  85. package/dist/platform/platform-value.js +41 -0
  86. package/dist/platform/slack-adapter.js +872 -0
  87. package/dist/platform/slack-markdown.js +169 -0
  88. package/dist/platform/types.js +4 -0
  89. package/dist/queue-advanced-e2e-setup.js +300 -0
  90. package/dist/queue-advanced-footer.e2e.test.js +173 -0
  91. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  92. package/dist/queue-advanced-permissions-typing.e2e.test.js +73 -1
  93. package/dist/queue-advanced-question.e2e.test.js +120 -0
  94. package/dist/queue-advanced-typing-interrupt.e2e.test.js +109 -0
  95. package/dist/queue-advanced-typing.e2e.test.js +0 -94
  96. package/dist/runtime-lifecycle.e2e.test.js +2 -20
  97. package/dist/session-handler/event-stream-state.js +5 -0
  98. package/dist/session-handler/event-stream-state.test.js +6 -2
  99. package/dist/session-handler/thread-session-runtime.js +32 -2
  100. package/dist/system-message.js +26 -23
  101. package/dist/test-utils.js +16 -0
  102. package/dist/thread-message-queue.e2e.test.js +37 -24
  103. package/dist/utils.js +3 -1
  104. package/dist/voice-message.e2e.test.js +2 -20
  105. package/dist/voice.js +122 -9
  106. package/dist/voice.test.js +17 -2
  107. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  108. package/package.json +7 -6
  109. package/skills/critique/SKILL.md +17 -0
  110. package/skills/egaki/SKILL.md +35 -0
  111. package/skills/event-sourcing-state/SKILL.md +98 -0
  112. package/skills/goke/SKILL.md +1 -0
  113. package/skills/npm-package/SKILL.md +21 -2
  114. package/skills/x-articles/SKILL.md +554 -0
  115. package/src/agent-model.e2e.test.ts +4 -19
  116. package/src/cli.ts +102 -40
  117. package/src/commands/ask-question.ts +36 -18
  118. package/src/commands/merge-worktree.ts +5 -21
  119. package/src/commands/new-worktree.ts +5 -11
  120. package/src/commands/permissions.ts +100 -15
  121. package/src/commands/resume.ts +5 -12
  122. package/src/commands/session.ts +6 -23
  123. package/src/discord-bot.ts +21 -14
  124. package/src/discord-utils.ts +53 -0
  125. package/src/event-stream-real-capture.e2e.test.ts +4 -20
  126. package/src/eventsource-parser.test.ts +351 -0
  127. package/src/gateway-proxy.e2e.test.ts +2 -5
  128. package/src/kimaki-digital-twin.e2e.test.ts +2 -21
  129. package/src/message-preprocessing.ts +4 -2
  130. package/src/opencode-command.test.ts +70 -0
  131. package/src/opencode-command.ts +188 -0
  132. package/src/opencode-interrupt-plugin.test.ts +0 -5
  133. package/src/opencode-interrupt-plugin.ts +34 -1
  134. package/src/opencode-plugin-loading.e2e.test.ts +25 -35
  135. package/src/opencode.ts +199 -32
  136. package/src/queue-advanced-e2e-setup.ts +309 -0
  137. package/src/queue-advanced-footer.e2e.test.ts +211 -0
  138. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  139. package/src/queue-advanced-permissions-typing.e2e.test.ts +92 -0
  140. package/src/queue-advanced-question.e2e.test.ts +158 -0
  141. package/src/queue-advanced-typing-interrupt.e2e.test.ts +138 -0
  142. package/src/queue-advanced-typing.e2e.test.ts +0 -112
  143. package/src/runtime-lifecycle.e2e.test.ts +4 -19
  144. package/src/session-handler/event-stream-state.test.ts +6 -2
  145. package/src/session-handler/event-stream-state.ts +5 -0
  146. package/src/session-handler/thread-session-runtime.ts +44 -2
  147. package/src/system-message.ts +26 -23
  148. package/src/test-utils.ts +17 -0
  149. package/src/thread-message-queue.e2e.test.ts +39 -24
  150. package/src/utils.ts +3 -1
  151. package/src/voice-message.e2e.test.ts +3 -20
  152. package/src/voice.test.ts +26 -2
  153. package/src/voice.ts +147 -9
  154. 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';
@@ -192,10 +193,11 @@ function exitNonInteractiveSetup() {
192
193
  }
193
194
  // Emit a structured JSON line on stdout for non-TTY consumers (cloud sandboxes, CI).
194
195
  // Each line is a self-contained JSON object with a "type" field for easy parsing.
195
- // Consumers can detect these by reading stdout line-by-line and JSON.parse-ing lines
196
- // that start with '{'.
196
+ // Lines are prefixed with "data: " and terminated with "\n\n" (SSE format) so consumers
197
+ // can use the eventsource-parser npm package to robustly extract JSON events from noisy
198
+ // process output (other log lines, warnings, etc. are ignored by the parser).
197
199
  function emitJsonEvent(event) {
198
- process.stdout.write(JSON.stringify(event) + '\n');
200
+ process.stdout.write(`data: ${JSON.stringify(event)}\n\n`);
199
201
  }
200
202
  async function resolveGatewayInstallCredentials() {
201
203
  if (!KIMAKI_GATEWAY_APP_ID) {
@@ -341,7 +343,11 @@ async function ensureCommandAvailable({ name, envPathKey, installUnix, installWi
341
343
  const foundInPath = await execAsync(`${whichCmd} ${name}`, {
342
344
  env: process.env,
343
345
  }).then((result) => {
344
- return result.stdout.trim();
346
+ const resolved = selectResolvedCommand({
347
+ output: result.stdout,
348
+ isWindows,
349
+ });
350
+ return resolved || '';
345
351
  }, () => {
346
352
  return '';
347
353
  });
@@ -958,6 +964,42 @@ function showReadyMessage({ kimakiChannels, createdChannels, }) {
958
964
  }
959
965
  note('Leave this process running to keep the bot active.\n\nIf you close this process or restart your machine, run `npx kimaki` again to start the bot.', '⚠️ Keep Running');
960
966
  }
967
+ /**
968
+ * Create the default kimaki channel in each guild and send a welcome message.
969
+ * Idempotent: skips guilds that already have the channel.
970
+ * Extracted so both the interactive and headless startup paths share the same logic.
971
+ */
972
+ async function ensureDefaultChannelsWithWelcome({ guilds, discordClient, appId, isGatewayMode, installerDiscordUserId, }) {
973
+ const created = [];
974
+ for (const guild of guilds) {
975
+ try {
976
+ const result = await createDefaultKimakiChannel({
977
+ guild,
978
+ botName: discordClient.user?.username,
979
+ appId,
980
+ isGatewayMode,
981
+ });
982
+ if (result) {
983
+ created.push({
984
+ name: result.channelName,
985
+ id: result.textChannelId,
986
+ guildId: guild.id,
987
+ });
988
+ // Send welcome message to the newly created default channel.
989
+ // Mention the installer so they get a notification.
990
+ const mentionUserId = installerDiscordUserId || guild.ownerId;
991
+ await sendWelcomeMessage({
992
+ channel: result.textChannel,
993
+ mentionUserId,
994
+ });
995
+ }
996
+ }
997
+ catch (error) {
998
+ cliLogger.warn(`Failed to create default kimaki channel in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
999
+ }
1000
+ }
1001
+ return created;
1002
+ }
961
1003
  /**
962
1004
  * Background initialization for quick start mode.
963
1005
  * Starts OpenCode server and registers slash commands without blocking bot startup.
@@ -1136,7 +1178,7 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
1136
1178
  emitJsonEvent({ type: 'install_url', url: oauthUrl });
1137
1179
  }
1138
1180
  // Poll until the user installs the bot in a Discord server.
1139
- // 600 attempts x 2s = 20 minutes timeout.
1181
+ // 100 attempts x 3s = 5 minutes timeout.
1140
1182
  const s = isInteractive ? spinner() : undefined;
1141
1183
  s?.start('Waiting for a Discord server with the bot installed...');
1142
1184
  const pollUrl = new URL('/api/onboarding/status', KIMAKI_WEBSITE_URL);
@@ -1144,9 +1186,9 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
1144
1186
  pollUrl.searchParams.set('secret', clientSecret);
1145
1187
  let guildId;
1146
1188
  let installerDiscordUserId;
1147
- for (let attempt = 0; attempt < 600; attempt++) {
1189
+ for (let attempt = 0; attempt < 100; attempt++) {
1148
1190
  await new Promise((resolve) => {
1149
- setTimeout(resolve, 2000);
1191
+ setTimeout(resolve, 3000);
1150
1192
  });
1151
1193
  // Progressive hints for interactive users who may be stuck
1152
1194
  if (isInteractive) {
@@ -1180,9 +1222,9 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
1180
1222
  s?.stop('Authorization timed out');
1181
1223
  }
1182
1224
  else {
1183
- emitJsonEvent({ type: 'error', message: 'Authorization timed out after 20 minutes' });
1225
+ emitJsonEvent({ type: 'error', message: 'Authorization timed out after 5 minutes' });
1184
1226
  }
1185
- 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.');
1186
1228
  process.exit(EXIT_NO_RESTART);
1187
1229
  }
1188
1230
  if (isInteractive) {
@@ -1454,7 +1496,8 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1454
1496
  cliLogger.log('Starting Discord bot...');
1455
1497
  await startDiscordBot({ token, appId, discordClient, useWorktrees });
1456
1498
  cliLogger.log('Discord bot is running!');
1457
- // Background channel sync + role reconciliation never blocks ready state.
1499
+ // Background channel sync + role reconciliation + default channel creation.
1500
+ // Never blocks ready state.
1458
1501
  void (async () => {
1459
1502
  try {
1460
1503
  const backgroundChannels = await collectKimakiChannels({
@@ -1467,6 +1510,20 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1467
1510
  catch (error) {
1468
1511
  cliLogger.warn('Background channel sync failed:', error instanceof Error ? error.message : String(error));
1469
1512
  }
1513
+ // Create default kimaki channel + welcome message in each guild.
1514
+ // Runs after channel sync so existing channels are detected correctly.
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
+ }
1470
1527
  })();
1471
1528
  // Background: OpenCode init + slash command registration (non-blocking)
1472
1529
  void backgroundInit({
@@ -1609,33 +1666,14 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1609
1666
  }
1610
1667
  // Create default kimaki channel for general-purpose tasks.
1611
1668
  // Runs for every guild the bot is in, idempotent (skips if already exists).
1612
- for (const guild of guilds) {
1613
- try {
1614
- const result = await createDefaultKimakiChannel({
1615
- guild,
1616
- botName: discordClient.user?.username,
1617
- appId,
1618
- isGatewayMode,
1619
- });
1620
- if (result) {
1621
- createdChannels.push({
1622
- name: result.channelName,
1623
- id: result.textChannelId,
1624
- guildId: guild.id,
1625
- });
1626
- // Send welcome message to the newly created default channel.
1627
- // Mention the installer so they get a notification.
1628
- const mentionUserId = installerDiscordUserId || guild.ownerId;
1629
- await sendWelcomeMessage({
1630
- channel: result.textChannel,
1631
- mentionUserId,
1632
- });
1633
- }
1634
- }
1635
- catch (error) {
1636
- cliLogger.warn(`Failed to create default kimaki channel in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
1637
- }
1638
- }
1669
+ const defaultChannelResults = await ensureDefaultChannelsWithWelcome({
1670
+ guilds,
1671
+ discordClient,
1672
+ appId,
1673
+ isGatewayMode,
1674
+ installerDiscordUserId,
1675
+ });
1676
+ createdChannels.push(...defaultChannelResults);
1639
1677
  // Log available user commands
1640
1678
  const registrableCommands = allUserCommands.filter((cmd) => !SKIP_USER_COMMANDS.includes(cmd.name));
1641
1679
  if (registrableCommands.length > 0) {
@@ -29,27 +29,28 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
29
29
  contextHash,
30
30
  };
31
31
  pendingQuestionContexts.set(contextHash, context);
32
- // Auto-answer on TTL expiry so the OpenCode session doesn't hang forever
33
- // waiting for a question reply that will never come.
32
+ // On TTL expiry: hide the dropdown UI and abort the session so OpenCode
33
+ // unblocks. We intentionally do NOT call question.reply() sending 'Other'
34
+ // made the model think the user chose an option when they didn't.
34
35
  setTimeout(async () => {
35
36
  const ctx = pendingQuestionContexts.get(contextHash);
36
37
  if (!ctx) {
37
38
  return;
38
39
  }
40
+ // Delete context first so the dropdown becomes inert immediately.
41
+ // Without this, a user clicking during the abort() await would still
42
+ // be accepted by handleAskQuestionSelectMenu, then abort() would
43
+ // kill that valid run.
44
+ pendingQuestionContexts.delete(contextHash);
45
+ // Abort the session so OpenCode isn't stuck waiting for a reply
39
46
  const client = getOpencodeClient(ctx.directory);
40
47
  if (client) {
41
- const answers = ctx.questions.map((_, i) => {
42
- return ctx.answers[i] || ['Other'];
43
- });
44
- await client.question.reply({
45
- requestID: ctx.requestId,
46
- directory: ctx.directory,
47
- answers,
48
+ await client.session.abort({
49
+ sessionID: ctx.sessionId,
48
50
  }).catch((error) => {
49
- logger.error('Failed to auto-answer expired question:', error);
51
+ logger.error('Failed to abort session after question expiry:', error);
50
52
  });
51
53
  }
52
- pendingQuestionContexts.delete(contextHash);
53
54
  }, QUESTION_CONTEXT_TTL_MS).unref();
54
55
  // Send one message per question with its dropdown directly underneath
55
56
  for (let i = 0; i < input.questions.length; i++) {
@@ -206,6 +207,11 @@ export function parseAskUserQuestionTool(part) {
206
207
  /**
207
208
  * Cancel a pending question for a thread (e.g., when user sends a new message).
208
209
  * Sends the user's message as the answer to OpenCode so the model sees their actual response.
210
+ *
211
+ * Returns 'replied' if the question was answered successfully (caller should NOT
212
+ * enqueue the user message as a new prompt — it was consumed as the answer).
213
+ * Returns 'reply-failed' if reply failed (context kept pending so TTL can retry).
214
+ * Returns 'no-pending' if no question was pending for this thread.
209
215
  */
210
216
  export async function cancelPendingQuestion(threadId, userMessage) {
211
217
  // Find pending question for this thread
@@ -219,17 +225,22 @@ export async function cancelPendingQuestion(threadId, userMessage) {
219
225
  }
220
226
  }
221
227
  if (!contextHash || !context) {
222
- return false;
228
+ return 'no-pending';
229
+ }
230
+ // undefined means teardown/cleanup — just remove context, don't reply.
231
+ // The session is already being torn down. Empty string '' is a valid
232
+ // user message (attachment-only, voice, etc.) and must still go through.
233
+ if (userMessage === undefined) {
234
+ pendingQuestionContexts.delete(contextHash);
235
+ return 'no-pending';
223
236
  }
224
237
  try {
225
238
  const client = getOpencodeClient(context.directory);
226
239
  if (!client) {
227
240
  throw new Error('OpenCode server not found for directory');
228
241
  }
229
- // Use user's message as answer if provided, otherwise mark as "Other"
230
- const customAnswer = userMessage || 'Other';
231
242
  const answers = context.questions.map((_, i) => {
232
- return context.answers[i] || [customAnswer];
243
+ return context.answers[i] || [userMessage];
233
244
  });
234
245
  await client.question.reply({
235
246
  requestID: context.requestId,
@@ -240,8 +251,10 @@ export async function cancelPendingQuestion(threadId, userMessage) {
240
251
  }
241
252
  catch (error) {
242
253
  logger.error('Failed to answer question:', error);
254
+ // Keep context pending so TTL can still fire.
255
+ // Caller should not consume the user message since reply failed.
256
+ return 'reply-failed';
243
257
  }
244
- // Clean up regardless of whether the API call succeeded
245
258
  pendingQuestionContexts.delete(contextHash);
246
- return true;
259
+ return 'replied';
247
260
  }
@@ -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;