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
@@ -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
  });
@@ -730,6 +735,16 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
730
735
  .setDescription('List and manage MCP servers for this project')
731
736
  .setDMPermission(false)
732
737
  .toJSON(),
738
+ new SlashCommandBuilder()
739
+ .setName('screenshare')
740
+ .setDescription('Start screen sharing via VNC tunnel (auto-stops after 1 hour)')
741
+ .setDMPermission(false)
742
+ .toJSON(),
743
+ new SlashCommandBuilder()
744
+ .setName('screenshare-stop')
745
+ .setDescription('Stop screen sharing')
746
+ .setDMPermission(false)
747
+ .toJSON(),
733
748
  ];
734
749
  // Add user-defined commands with source-based suffixes (-cmd / -skill)
735
750
  // Also populate registeredUserCommands in the store for /queue-command autocomplete
@@ -1173,7 +1188,7 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
1173
1188
  emitJsonEvent({ type: 'install_url', url: oauthUrl });
1174
1189
  }
1175
1190
  // Poll until the user installs the bot in a Discord server.
1176
- // 600 attempts x 2s = 20 minutes timeout.
1191
+ // 100 attempts x 3s = 5 minutes timeout.
1177
1192
  const s = isInteractive ? spinner() : undefined;
1178
1193
  s?.start('Waiting for a Discord server with the bot installed...');
1179
1194
  const pollUrl = new URL('/api/onboarding/status', KIMAKI_WEBSITE_URL);
@@ -1181,9 +1196,9 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
1181
1196
  pollUrl.searchParams.set('secret', clientSecret);
1182
1197
  let guildId;
1183
1198
  let installerDiscordUserId;
1184
- for (let attempt = 0; attempt < 600; attempt++) {
1199
+ for (let attempt = 0; attempt < 100; attempt++) {
1185
1200
  await new Promise((resolve) => {
1186
- setTimeout(resolve, 2000);
1201
+ setTimeout(resolve, 3000);
1187
1202
  });
1188
1203
  // Progressive hints for interactive users who may be stuck
1189
1204
  if (isInteractive) {
@@ -1217,9 +1232,9 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
1217
1232
  s?.stop('Authorization timed out');
1218
1233
  }
1219
1234
  else {
1220
- emitJsonEvent({ type: 'error', message: 'Authorization timed out after 20 minutes' });
1235
+ emitJsonEvent({ type: 'error', message: 'Authorization timed out after 5 minutes' });
1221
1236
  }
1222
- cliLogger.error('Bot authorization timed out after 20 minutes. Please try again.');
1237
+ cliLogger.error('Bot authorization timed out after 5 minutes. Please try again.');
1223
1238
  process.exit(EXIT_NO_RESTART);
1224
1239
  }
1225
1240
  if (isInteractive) {
@@ -1507,13 +1522,18 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1507
1522
  }
1508
1523
  // Create default kimaki channel + welcome message in each guild.
1509
1524
  // Runs after channel sync so existing channels are detected correctly.
1510
- await ensureDefaultChannelsWithWelcome({
1511
- guilds,
1512
- discordClient,
1513
- appId,
1514
- isGatewayMode,
1515
- installerDiscordUserId,
1516
- });
1525
+ try {
1526
+ await ensureDefaultChannelsWithWelcome({
1527
+ guilds,
1528
+ discordClient,
1529
+ appId,
1530
+ isGatewayMode,
1531
+ installerDiscordUserId,
1532
+ });
1533
+ }
1534
+ catch (error) {
1535
+ cliLogger.warn('Background default channel creation failed:', error instanceof Error ? error.message : String(error));
1536
+ }
1517
1537
  })();
1518
1538
  // Background: OpenCode init + slash command registration (non-blocking)
1519
1539
  void backgroundInit({
@@ -2927,6 +2947,23 @@ cli
2927
2947
  command: command.length > 0 ? command : undefined,
2928
2948
  });
2929
2949
  });
2950
+ cli
2951
+ .command('screenshare', 'Share your screen via VNC tunnel. Auto-stops after 1 hour. Runs until Ctrl+C. Use tmux to run in background.')
2952
+ .action(async () => {
2953
+ const { startScreenshare } = await import('./commands/screenshare.js');
2954
+ try {
2955
+ const session = await startScreenshare({
2956
+ sessionKey: 'cli',
2957
+ startedBy: 'cli',
2958
+ });
2959
+ cliLogger.log(`Screen sharing started: ${session.noVncUrl}`);
2960
+ cliLogger.log('Press Ctrl+C to stop');
2961
+ }
2962
+ catch (err) {
2963
+ cliLogger.error('Failed to start screen share:', err instanceof Error ? err.message : String(err));
2964
+ process.exit(EXIT_NO_RESTART);
2965
+ }
2966
+ });
2930
2967
  cli
2931
2968
  .command('sqlitedb', 'Show the location of the SQLite database file')
2932
2969
  .action(() => {
@@ -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
+ }
@@ -3,7 +3,7 @@ import { ChannelType, EmbedBuilder, MessageFlags, } from 'discord.js';
3
3
  import path from 'node:path';
4
4
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
5
5
  import { createLogger, LogPrefix } from '../logger.js';
6
- import { execAsync } from '../worktrees.js';
6
+ import { uploadGitDiffViaCritique } from '../critique-utils.js';
7
7
  const logger = createLogger(LogPrefix.DIFF);
8
8
  export async function handleDiffCommand({ command, }) {
9
9
  const channel = command.channel;
@@ -39,90 +39,25 @@ export async function handleDiffCommand({ command, }) {
39
39
  }
40
40
  const { workingDirectory } = resolved;
41
41
  await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
42
- try {
43
- const projectName = path.basename(workingDirectory);
44
- const title = `${projectName}: Discord /diff`;
45
- const { stdout, stderr } = await execAsync(`bunx critique --web "${title}" --json`, {
46
- cwd: workingDirectory,
47
- timeout: 30000,
48
- });
49
- // critique --json outputs JSON on the last line: {"url":"...","id":"..."} or {"error":"..."}
50
- const output = stdout || stderr;
51
- const lines = output.trim().split('\n');
52
- const jsonLine = lines[lines.length - 1];
53
- if (!jsonLine) {
54
- await command.editReply({
55
- content: 'No changes to show',
56
- });
57
- return;
58
- }
59
- let result;
60
- try {
61
- result = JSON.parse(jsonLine);
62
- }
63
- catch {
64
- // Fallback: try to find URL in output
65
- const urlMatch = output.match(/https?:\/\/critique\.work\/[^\s]+/);
66
- if (urlMatch) {
67
- await command.editReply({
68
- content: `[diff](${urlMatch[0]})`,
69
- });
70
- logger.log(`Diff shared: ${urlMatch[0]}`);
71
- return;
72
- }
73
- await command.editReply({
74
- content: 'No changes to show',
75
- });
76
- return;
77
- }
78
- if (result.error || !result.url || !result.id) {
79
- await command.editReply({
80
- content: result.error || 'No changes to show',
81
- });
82
- return;
83
- }
84
- const imageUrl = `https://critique.work/og/${result.id}.png`;
85
- const embed = new EmbedBuilder()
86
- .setTitle(title)
87
- .setURL(result.url)
88
- .setImage(imageUrl);
89
- await command.editReply({
90
- embeds: [embed],
91
- });
92
- logger.log(`Diff shared: ${result.url}`);
42
+ const projectName = path.basename(workingDirectory);
43
+ const title = `${projectName}: Discord /diff`;
44
+ const result = await uploadGitDiffViaCritique({
45
+ title,
46
+ cwd: workingDirectory,
47
+ });
48
+ if (!result) {
49
+ await command.editReply({ content: 'No changes to show' });
50
+ return;
93
51
  }
94
- catch (error) {
95
- logger.error('[DIFF] Error:', error);
96
- // exec error includes stdout/stderr - try to parse JSON from it
97
- const execError = error;
98
- const output = execError.stdout || execError.stderr || '';
99
- // Check if critique output JSON even on error
100
- const lines = output.trim().split('\n');
101
- const jsonLine = lines[lines.length - 1];
102
- if (jsonLine) {
103
- try {
104
- const result = JSON.parse(jsonLine);
105
- if (result.error) {
106
- await command.editReply({
107
- content: result.error,
108
- });
109
- return;
110
- }
111
- }
112
- catch {
113
- // not JSON, continue to generic error
114
- }
115
- }
116
- // Check for common errors
117
- const message = execError.message || 'Unknown error';
118
- if (message.includes('command not found') || message.includes('ENOENT')) {
119
- await command.editReply({
120
- content: 'bunx/critique not available',
121
- });
122
- return;
123
- }
124
- await command.editReply({
125
- content: `Failed to generate diff: ${message.slice(0, 200)}`,
126
- });
52
+ if (result.error || !result.url) {
53
+ await command.editReply({ content: result.error || 'No changes to show' });
54
+ return;
127
55
  }
56
+ const imageUrl = `https://critique.work/og/${result.id}.png`;
57
+ const embed = new EmbedBuilder()
58
+ .setTitle(title)
59
+ .setURL(result.url)
60
+ .setImage(imageUrl);
61
+ await command.editReply({ embeds: [embed] });
62
+ logger.log(`Diff shared: ${result.url}`);
128
63
  }
@@ -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;