kimaki 0.4.29 → 0.4.31

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kimaki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { createLogger } from './logger.js';
13
13
  import { spawn, spawnSync, execSync } from 'node:child_process';
14
14
  import http from 'node:http';
15
15
  import { setDataDir, getDataDir, getLockPort } from './config.js';
16
+ import { extractTagsArrays } from './xml.js';
16
17
  const cliLogger = createLogger('CLI');
17
18
  const cli = cac('kimaki');
18
19
  process.title = 'kimaki';
@@ -387,7 +388,12 @@ async function run({ restart, addChannels }) {
387
388
  }
388
389
  }
389
390
  const s = spinner();
390
- s.start('Creating Discord client and connecting...');
391
+ // Start OpenCode server EARLY - let it initialize in parallel with Discord login.
392
+ // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
393
+ const currentDir = process.cwd();
394
+ s.start('Starting OpenCode server...');
395
+ const opencodePromise = initializeOpencodeForDirectory(currentDir);
396
+ s.message('Connecting to Discord...');
391
397
  const discordClient = await createDiscordClient();
392
398
  const guilds = [];
393
399
  const kimakiChannels = [];
@@ -396,11 +402,43 @@ async function run({ restart, addChannels }) {
396
402
  await new Promise((resolve, reject) => {
397
403
  discordClient.once(Events.ClientReady, async (c) => {
398
404
  guilds.push(...Array.from(c.guilds.cache.values()));
399
- for (const guild of guilds) {
405
+ // Process all guilds in parallel for faster startup
406
+ const guildResults = await Promise.all(guilds.map(async (guild) => {
407
+ // Create Kimaki role if it doesn't exist, or fix its position (fire-and-forget)
408
+ guild.roles
409
+ .fetch()
410
+ .then(async (roles) => {
411
+ const existingRole = roles.find((role) => role.name.toLowerCase() === 'kimaki');
412
+ if (existingRole) {
413
+ // Move to bottom if not already there
414
+ if (existingRole.position > 1) {
415
+ await existingRole.setPosition(1);
416
+ cliLogger.info(`Moved "Kimaki" role to bottom in ${guild.name}`);
417
+ }
418
+ return;
419
+ }
420
+ return guild.roles.create({
421
+ name: 'Kimaki',
422
+ position: 1, // Place at bottom so anyone with Manage Roles can assign it
423
+ reason: 'Kimaki bot permission role - assign to users who can start sessions, send messages in threads, and use voice features',
424
+ });
425
+ })
426
+ .then((role) => {
427
+ if (role) {
428
+ cliLogger.info(`Created "Kimaki" role in ${guild.name}`);
429
+ }
430
+ })
431
+ .catch((error) => {
432
+ cliLogger.warn(`Could not create Kimaki role in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
433
+ });
400
434
  const channels = await getChannelsWithDescriptions(guild);
401
435
  const kimakiChans = channels.filter((ch) => ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId));
402
- if (kimakiChans.length > 0) {
403
- kimakiChannels.push({ guild, channels: kimakiChans });
436
+ return { guild, channels: kimakiChans };
437
+ }));
438
+ // Collect results
439
+ for (const result of guildResults) {
440
+ if (result.channels.length > 0) {
441
+ kimakiChannels.push(result);
404
442
  }
405
443
  }
406
444
  resolve(null);
@@ -440,26 +478,22 @@ async function run({ restart, addChannels }) {
440
478
  .join('\n');
441
479
  note(channelList, 'Existing Kimaki Channels');
442
480
  }
443
- s.start('Starting OpenCode server...');
444
- const currentDir = process.cwd();
445
- let getClient = await initializeOpencodeForDirectory(currentDir);
446
- s.stop('OpenCode server started!');
447
- s.start('Fetching OpenCode projects...');
448
- let projects = [];
449
- try {
450
- const projectsResponse = await getClient().project.list({});
451
- if (!projectsResponse.data) {
452
- throw new Error('Failed to fetch projects');
453
- }
454
- projects = projectsResponse.data;
455
- s.stop(`Found ${projects.length} OpenCode project(s)`);
456
- }
457
- catch (error) {
458
- s.stop('Failed to fetch projects');
459
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
460
- discordClient.destroy();
461
- process.exit(EXIT_NO_RESTART);
462
- }
481
+ // Await the OpenCode server that was started in parallel with Discord login
482
+ s.start('Waiting for OpenCode server...');
483
+ const getClient = await opencodePromise;
484
+ s.stop('OpenCode server ready!');
485
+ s.start('Fetching OpenCode data...');
486
+ // Fetch projects and commands in parallel
487
+ const [projects, allUserCommands] = await Promise.all([
488
+ getClient().project.list({}).then((r) => r.data || []).catch((error) => {
489
+ s.stop('Failed to fetch projects');
490
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
491
+ discordClient.destroy();
492
+ process.exit(EXIT_NO_RESTART);
493
+ }),
494
+ getClient().command.list({ query: { directory: currentDir } }).then((r) => r.data || []).catch(() => []),
495
+ ]);
496
+ s.stop(`Found ${projects.length} OpenCode project(s)`);
463
497
  const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
464
498
  .filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
465
499
  .map((ch) => ch.kimakiDirectory)
@@ -540,19 +574,6 @@ async function run({ restart, addChannels }) {
540
574
  }
541
575
  }
542
576
  }
543
- // Fetch user-defined commands using the already-running server
544
- const allUserCommands = [];
545
- try {
546
- const commandsResponse = await getClient().command.list({
547
- query: { directory: currentDir },
548
- });
549
- if (commandsResponse.data) {
550
- allUserCommands.push(...commandsResponse.data);
551
- }
552
- }
553
- catch {
554
- // Ignore errors fetching commands
555
- }
556
577
  // Log available user commands
557
578
  const registrableCommands = allUserCommands.filter((cmd) => !SKIP_USER_COMMANDS.includes(cmd.name));
558
579
  if (registrableCommands.length > 0) {
@@ -597,6 +618,7 @@ cli
597
618
  .option('--restart', 'Prompt for new credentials even if saved')
598
619
  .option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
599
620
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
621
+ .option('--install-url', 'Print the bot install URL and exit')
600
622
  .action(async (options) => {
601
623
  try {
602
624
  // Set data directory early, before any database access
@@ -604,6 +626,18 @@ cli
604
626
  setDataDir(options.dataDir);
605
627
  cliLogger.log(`Using data directory: ${getDataDir()}`);
606
628
  }
629
+ if (options.installUrl) {
630
+ const db = getDatabase();
631
+ const existingBot = db
632
+ .prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
633
+ .get();
634
+ if (!existingBot) {
635
+ cliLogger.error('No bot configured yet. Run `kimaki` first to set up.');
636
+ process.exit(EXIT_NO_RESTART);
637
+ }
638
+ console.log(generateBotInstallUrl({ clientId: existingBot.app_id }));
639
+ process.exit(0);
640
+ }
607
641
  await checkSingleInstance();
608
642
  await startLockServer();
609
643
  await run({
@@ -683,5 +717,149 @@ cli
683
717
  process.exit(EXIT_NO_RESTART);
684
718
  }
685
719
  });
720
+ // Magic prefix used to identify bot-initiated sessions.
721
+ // The running bot will recognize this prefix and start a session.
722
+ const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
723
+ cli
724
+ .command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
725
+ .option('-c, --channel <channelId>', 'Discord channel ID')
726
+ .option('-p, --prompt <prompt>', 'Initial prompt for the session')
727
+ .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
728
+ .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
729
+ .action(async (options) => {
730
+ try {
731
+ const { channel: channelId, prompt, name, appId: optionAppId } = options;
732
+ if (!channelId) {
733
+ cliLogger.error('Channel ID is required. Use --channel <channelId>');
734
+ process.exit(EXIT_NO_RESTART);
735
+ }
736
+ if (!prompt) {
737
+ cliLogger.error('Prompt is required. Use --prompt <prompt>');
738
+ process.exit(EXIT_NO_RESTART);
739
+ }
740
+ // Get bot token from env var or database
741
+ const envToken = process.env.KIMAKI_BOT_TOKEN;
742
+ let botToken;
743
+ let appId = optionAppId;
744
+ if (envToken) {
745
+ botToken = envToken;
746
+ if (!appId) {
747
+ // Try to get app_id from database if available (optional in CI)
748
+ try {
749
+ const db = getDatabase();
750
+ const botRow = db
751
+ .prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
752
+ .get();
753
+ appId = botRow?.app_id;
754
+ }
755
+ catch {
756
+ // Database might not exist in CI, that's ok
757
+ }
758
+ }
759
+ }
760
+ else {
761
+ // Fall back to database
762
+ try {
763
+ const db = getDatabase();
764
+ const botRow = db
765
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
766
+ .get();
767
+ if (botRow) {
768
+ botToken = botRow.token;
769
+ appId = appId || botRow.app_id;
770
+ }
771
+ }
772
+ catch (e) {
773
+ // Database error - will fall through to the check below
774
+ cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
775
+ }
776
+ }
777
+ if (!botToken) {
778
+ cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
779
+ process.exit(EXIT_NO_RESTART);
780
+ }
781
+ const s = spinner();
782
+ s.start('Fetching channel info...');
783
+ // Get channel info to extract directory from topic
784
+ const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
785
+ headers: {
786
+ 'Authorization': `Bot ${botToken}`,
787
+ },
788
+ });
789
+ if (!channelResponse.ok) {
790
+ const error = await channelResponse.text();
791
+ s.stop('Failed to fetch channel');
792
+ throw new Error(`Discord API error: ${channelResponse.status} - ${error}`);
793
+ }
794
+ const channelData = await channelResponse.json();
795
+ if (!channelData.topic) {
796
+ s.stop('Channel has no topic');
797
+ throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`);
798
+ }
799
+ const extracted = extractTagsArrays({
800
+ xml: channelData.topic,
801
+ tags: ['kimaki.directory', 'kimaki.app'],
802
+ });
803
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
804
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim();
805
+ if (!projectDirectory) {
806
+ s.stop('No kimaki.directory tag found');
807
+ throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`);
808
+ }
809
+ // Verify app ID matches if both are present
810
+ if (channelAppId && appId && channelAppId !== appId) {
811
+ s.stop('Channel belongs to different bot');
812
+ throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`);
813
+ }
814
+ s.message('Creating starter message...');
815
+ // Create starter message with magic prefix
816
+ // The full prompt goes in the message so the bot can read it
817
+ const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
818
+ method: 'POST',
819
+ headers: {
820
+ 'Authorization': `Bot ${botToken}`,
821
+ 'Content-Type': 'application/json',
822
+ },
823
+ body: JSON.stringify({
824
+ content: `${BOT_SESSION_PREFIX}\n${prompt}`,
825
+ }),
826
+ });
827
+ if (!starterMessageResponse.ok) {
828
+ const error = await starterMessageResponse.text();
829
+ s.stop('Failed to create message');
830
+ throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
831
+ }
832
+ const starterMessage = await starterMessageResponse.json();
833
+ s.message('Creating thread...');
834
+ // Create thread from the message
835
+ const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt);
836
+ const threadResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`, {
837
+ method: 'POST',
838
+ headers: {
839
+ 'Authorization': `Bot ${botToken}`,
840
+ 'Content-Type': 'application/json',
841
+ },
842
+ body: JSON.stringify({
843
+ name: threadName.slice(0, 100),
844
+ auto_archive_duration: 1440, // 1 day
845
+ }),
846
+ });
847
+ if (!threadResponse.ok) {
848
+ const error = await threadResponse.text();
849
+ s.stop('Failed to create thread');
850
+ throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
851
+ }
852
+ const threadData = await threadResponse.json();
853
+ s.stop('Thread created!');
854
+ const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
855
+ note(`Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`, '✅ Thread Created');
856
+ console.log(threadUrl);
857
+ process.exit(0);
858
+ }
859
+ catch (error) {
860
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
861
+ process.exit(EXIT_NO_RESTART);
862
+ }
863
+ });
686
864
  cli.help();
687
865
  cli.parse();
@@ -4,7 +4,7 @@
4
4
  import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder, } from 'discord.js';
5
5
  import crypto from 'node:crypto';
6
6
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
7
- import { getOpencodeServerPort } from '../opencode.js';
7
+ import { getOpencodeClientV2 } from '../opencode.js';
8
8
  import { createLogger } from '../logger.js';
9
9
  const logger = createLogger('ASK_QUESTION');
10
10
  // Store pending question contexts by hash
@@ -128,25 +128,18 @@ export async function handleAskQuestionSelectMenu(interaction) {
128
128
  */
129
129
  async function submitQuestionAnswers(context) {
130
130
  try {
131
+ const clientV2 = getOpencodeClientV2(context.directory);
132
+ if (!clientV2) {
133
+ throw new Error('OpenCode server not found for directory');
134
+ }
131
135
  // Build answers array: each element is an array of selected labels for that question
132
- const answersPayload = context.questions.map((_, i) => {
136
+ const answers = context.questions.map((_, i) => {
133
137
  return context.answers[i] || [];
134
138
  });
135
- // Reply to the question using direct HTTP call to OpenCode API
136
- // (v1 SDK doesn't have question.reply, so we call it directly)
137
- const port = getOpencodeServerPort(context.directory);
138
- if (!port) {
139
- throw new Error('OpenCode server not found for directory');
140
- }
141
- const response = await fetch(`http://127.0.0.1:${port}/question/${context.requestId}/reply`, {
142
- method: 'POST',
143
- headers: { 'Content-Type': 'application/json' },
144
- body: JSON.stringify({ answers: answersPayload }),
139
+ await clientV2.question.reply({
140
+ requestID: context.requestId,
141
+ answers,
145
142
  });
146
- if (!response.ok) {
147
- const text = await response.text();
148
- throw new Error(`Failed to reply to question: ${response.status} ${text}`);
149
- }
150
143
  logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
151
144
  }
152
145
  catch (error) {
@@ -182,3 +175,43 @@ export function parseAskUserQuestionTool(part) {
182
175
  }
183
176
  return input;
184
177
  }
178
+ /**
179
+ * Cancel a pending question for a thread (e.g., when user sends a new message).
180
+ * Sends cancellation response to OpenCode so the session can continue.
181
+ */
182
+ export async function cancelPendingQuestion(threadId) {
183
+ // Find pending question for this thread
184
+ let contextHash;
185
+ let context;
186
+ for (const [hash, ctx] of pendingQuestionContexts) {
187
+ if (ctx.thread.id === threadId) {
188
+ contextHash = hash;
189
+ context = ctx;
190
+ break;
191
+ }
192
+ }
193
+ if (!contextHash || !context) {
194
+ return false;
195
+ }
196
+ try {
197
+ const clientV2 = getOpencodeClientV2(context.directory);
198
+ if (!clientV2) {
199
+ throw new Error('OpenCode server not found for directory');
200
+ }
201
+ // Preserve already-answered questions, mark unanswered as cancelled
202
+ const answers = context.questions.map((_, i) => {
203
+ return context.answers[i] || ['(cancelled - user sent new message)'];
204
+ });
205
+ await clientV2.question.reply({
206
+ requestID: context.requestId,
207
+ answers,
208
+ });
209
+ logger.log(`Cancelled question ${context.requestId} due to new user message`);
210
+ }
211
+ catch (error) {
212
+ logger.error('Failed to cancel question:', error);
213
+ }
214
+ // Clean up regardless of whether the API call succeeded
215
+ pendingQuestionContexts.delete(contextHash);
216
+ return true;
217
+ }
@@ -109,7 +109,10 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
109
109
  const canManageServer = message.member.permissions.has(PermissionsBitField.Flags.ManageGuild);
110
110
  const hasKimakiRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
111
111
  if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
112
- await message.react('🔒');
112
+ await message.reply({
113
+ content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
114
+ flags: SILENT_MESSAGE_FLAGS,
115
+ });
113
116
  return;
114
117
  }
115
118
  }
@@ -296,6 +299,85 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
296
299
  }
297
300
  }
298
301
  });
302
+ // Magic prefix used by `kimaki start-session` CLI command to initiate sessions
303
+ const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
304
+ // Handle bot-initiated threads created by `kimaki start-session`
305
+ discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
306
+ try {
307
+ if (!newlyCreated) {
308
+ return;
309
+ }
310
+ // Only handle threads in text channels
311
+ const parent = thread.parent;
312
+ if (!parent || parent.type !== ChannelType.GuildText) {
313
+ return;
314
+ }
315
+ // Get the starter message to check for magic prefix
316
+ const starterMessage = await thread.fetchStarterMessage().catch(() => null);
317
+ if (!starterMessage) {
318
+ discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
319
+ return;
320
+ }
321
+ // Only handle messages from this bot with the magic prefix
322
+ if (starterMessage.author.id !== discordClient.user?.id) {
323
+ return;
324
+ }
325
+ if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
326
+ return;
327
+ }
328
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
329
+ // Extract the prompt (everything after the prefix)
330
+ const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim();
331
+ if (!prompt) {
332
+ discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
333
+ return;
334
+ }
335
+ // Extract directory from parent channel topic
336
+ if (!parent.topic) {
337
+ discordLogger.log(`[BOT_SESSION] Parent channel has no topic`);
338
+ return;
339
+ }
340
+ const extracted = extractTagsArrays({
341
+ xml: parent.topic,
342
+ tags: ['kimaki.directory', 'kimaki.app'],
343
+ });
344
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
345
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim();
346
+ if (!projectDirectory) {
347
+ discordLogger.log(`[BOT_SESSION] No kimaki.directory in parent channel topic`);
348
+ return;
349
+ }
350
+ if (channelAppId && channelAppId !== currentAppId) {
351
+ discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`);
352
+ return;
353
+ }
354
+ if (!fs.existsSync(projectDirectory)) {
355
+ discordLogger.error(`[BOT_SESSION] Directory does not exist: ${projectDirectory}`);
356
+ await thread.send({
357
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
358
+ flags: SILENT_MESSAGE_FLAGS,
359
+ });
360
+ return;
361
+ }
362
+ discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
363
+ await handleOpencodeSession({
364
+ prompt,
365
+ thread,
366
+ projectDirectory,
367
+ channelId: parent.id,
368
+ });
369
+ }
370
+ catch (error) {
371
+ voiceLogger.error('[BOT_SESSION] Error handling bot-initiated thread:', error);
372
+ try {
373
+ const errMsg = error instanceof Error ? error.message : String(error);
374
+ await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS });
375
+ }
376
+ catch {
377
+ // Ignore send errors
378
+ }
379
+ }
380
+ });
299
381
  await discordClient.login(token);
300
382
  const handleShutdown = async (signal, { skipExit = false } = {}) => {
301
383
  discordLogger.log(`Received ${signal}, cleaning up...`);
@@ -5,6 +5,7 @@ import { ChannelType, } from 'discord.js';
5
5
  import { Lexer } from 'marked';
6
6
  import { extractTagsArrays } from './xml.js';
7
7
  import { formatMarkdownTables } from './format-tables.js';
8
+ import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
8
9
  import { createLogger } from './logger.js';
9
10
  const discordLogger = createLogger('DISCORD');
10
11
  export const SILENT_MESSAGE_FLAGS = 4 | 4096;
@@ -92,7 +93,8 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
92
93
  }
93
94
  // calculate overhead for code block markers
94
95
  const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0;
95
- const availablePerChunk = maxLength - codeBlockOverhead - 50; // safety margin
96
+ // ensure at least 10 chars available, even if maxLength is very small
97
+ const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50);
96
98
  const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
97
99
  for (let i = 0; i < pieces.length; i++) {
98
100
  const piece = pieces[i];
@@ -156,6 +158,7 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
156
158
  export async function sendThreadMessage(thread, content, options) {
157
159
  const MAX_LENGTH = 2000;
158
160
  content = formatMarkdownTables(content);
161
+ content = unnestCodeBlocksFromLists(content);
159
162
  content = escapeBackticksInCodeBlocks(content);
160
163
  // If custom flags provided, send as single message (no chunking)
161
164
  if (options?.flags !== undefined) {
@@ -341,11 +341,19 @@ test('splitMarkdownForDiscord handles very long line inside code block', () => {
341
341
  \`\`\`
342
342
  ",
343
343
  "\`\`\`js
344
- veryverylonglinethatexceedsmaxlength
345
- \`\`\`
344
+ veryverylo\`\`\`
346
345
  ",
347
346
  "\`\`\`js
348
- short
347
+ nglinethat\`\`\`
348
+ ",
349
+ "\`\`\`js
350
+ exceedsmax\`\`\`
351
+ ",
352
+ "\`\`\`js
353
+ length
354
+ \`\`\`
355
+ ",
356
+ "short
349
357
  \`\`\`
350
358
  ",
351
359
  ]
@@ -4,12 +4,12 @@
4
4
  import prettyMilliseconds from 'pretty-ms';
5
5
  import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js';
6
6
  import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
7
- import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js';
7
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
8
8
  import { formatPart } from './message-formatting.js';
9
9
  import { getOpencodeSystemMessage } from './system-message.js';
10
10
  import { createLogger } from './logger.js';
11
11
  import { isAbortError } from './utils.js';
12
- import { showAskUserQuestionDropdowns } from './commands/ask-question.js';
12
+ import { showAskUserQuestionDropdowns, cancelPendingQuestion } from './commands/ask-question.js';
13
13
  import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js';
14
14
  const sessionLogger = createLogger('SESSION');
15
15
  const voiceLogger = createLogger('VOICE');
@@ -154,6 +154,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
154
154
  pendingPermissions.delete(thread.id);
155
155
  }
156
156
  }
157
+ // Cancel any pending question tool if user sends a new message
158
+ const questionCancelled = await cancelPendingQuestion(thread.id);
159
+ if (questionCancelled) {
160
+ sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`);
161
+ await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`);
162
+ }
157
163
  const abortController = new AbortController();
158
164
  abortControllers.set(session.id, abortController);
159
165
  if (existingController) {
@@ -281,7 +287,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
281
287
  const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
282
288
  if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
283
289
  lastDisplayedContextPercentage = thresholdCrossed;
284
- await sendThreadMessage(thread, `⬥ context usage ${currentPercentage}%`);
290
+ const chunk = `⬦ context usage ${currentPercentage}%`;
291
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
285
292
  }
286
293
  }
287
294
  }
@@ -386,6 +393,13 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
386
393
  continue;
387
394
  }
388
395
  sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
396
+ // Flush any pending text/reasoning parts before showing the dropdown
397
+ // This ensures text the LLM generated before the question tool is shown first
398
+ for (const p of currentParts) {
399
+ if (p.type !== 'step-start' && p.type !== 'step-finish') {
400
+ await sendPartMessage(p);
401
+ }
402
+ }
389
403
  await showAskUserQuestionDropdowns({
390
404
  thread,
391
405
  sessionId: session.id,
@@ -525,7 +539,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
525
539
  path: { id: session.id },
526
540
  body: {
527
541
  parts,
528
- system: getOpencodeSystemMessage({ sessionId: session.id }),
542
+ system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
529
543
  model: modelParam,
530
544
  agent: agentPreference,
531
545
  },