kimaki 0.19.0 → 0.20.1

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.
@@ -56,74 +56,7 @@ cli
56
56
  void client.login(botToken);
57
57
  });
58
58
  cliLogger.log('Finding guild...');
59
- // Find guild
60
- let guild;
61
- if (options.guild) {
62
- const guildId = String(options.guild);
63
- const foundGuild = client.guilds.cache.get(guildId);
64
- if (!foundGuild) {
65
- cliLogger.log('Guild not found');
66
- cliLogger.error(`Guild not found: ${guildId}`);
67
- void client.destroy();
68
- process.exit(EXIT_NO_RESTART);
69
- }
70
- guild = foundGuild;
71
- }
72
- else {
73
- const existingChannelId = await (await getDb()).query.channel_directories.findFirst({
74
- where: { channel_type: 'text' },
75
- orderBy: { created_at: 'desc' },
76
- columns: { channel_id: true },
77
- }).then((row) => row?.channel_id);
78
- if (existingChannelId) {
79
- try {
80
- const ch = await client.channels.fetch(existingChannelId);
81
- if (ch && !ch.isDMBased()) {
82
- guild = ch.guild;
83
- }
84
- else {
85
- throw new Error('Channel has no guild');
86
- }
87
- }
88
- catch (error) {
89
- cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.stack : String(error));
90
- let firstGuild = client.guilds.cache.first();
91
- if (!firstGuild) {
92
- // Cache might be empty, try fetching guilds from API
93
- const fetched = await client.guilds.fetch();
94
- const firstOAuth2Guild = fetched.first();
95
- if (firstOAuth2Guild) {
96
- firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
97
- }
98
- }
99
- if (!firstGuild) {
100
- cliLogger.log('No guild found');
101
- cliLogger.error('No guild found. Add the bot to a server first.');
102
- void client.destroy();
103
- process.exit(EXIT_NO_RESTART);
104
- }
105
- guild = firstGuild;
106
- }
107
- }
108
- else {
109
- let firstGuild = client.guilds.cache.first();
110
- if (!firstGuild) {
111
- // Cache might be empty, try fetching guilds from API
112
- const fetched = await client.guilds.fetch();
113
- const firstOAuth2Guild = fetched.first();
114
- if (firstOAuth2Guild) {
115
- firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
116
- }
117
- }
118
- if (!firstGuild) {
119
- cliLogger.log('No guild found');
120
- cliLogger.error('No guild found. Add the bot to a server first.');
121
- void client.destroy();
122
- process.exit(EXIT_NO_RESTART);
123
- }
124
- guild = firstGuild;
125
- }
126
- }
59
+ const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild });
127
60
  // Check if channel already exists in this guild
128
61
  cliLogger.log('Checking for existing channel...');
129
62
  try {
@@ -353,25 +286,7 @@ cli
353
286
  client.once(Events.Error, reject);
354
287
  client.login(botToken).catch(reject);
355
288
  });
356
- let guild;
357
- if (options.guild) {
358
- const found = client.guilds.cache.get(options.guild);
359
- if (!found) {
360
- cliLogger.error(`Guild not found: ${options.guild}`);
361
- void client.destroy();
362
- process.exit(EXIT_NO_RESTART);
363
- }
364
- guild = found;
365
- }
366
- else {
367
- const first = client.guilds.cache.first();
368
- if (!first) {
369
- cliLogger.error('No guild found. Add the bot to a server first.');
370
- void client.destroy();
371
- process.exit(EXIT_NO_RESTART);
372
- }
373
- guild = first;
374
- }
289
+ const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild });
375
290
  const { textChannelId, channelName } = await createProjectChannels({
376
291
  guild,
377
292
  projectDirectory,
@@ -383,4 +298,87 @@ cli
383
298
  cliLogger.log(channelUrl);
384
299
  process.exit(0);
385
300
  });
301
+ // Resolve the guild for project add/create commands. In gateway mode the
302
+ // guild cache only contains authorized guilds, so picking from cache is safe.
303
+ // The old approach fetched an existing channel to infer the guild, but that
304
+ // breaks when the channel belongs to a different guild (e.g. old self-hosted
305
+ // bot channels) and the gateway proxy rejects the REST call. This led to a
306
+ // non-deterministic fallback that picked the wrong guild.
307
+ async function resolveGuildForProjectCommand({ client, guildIdOverride }) {
308
+ if (guildIdOverride) {
309
+ const found = client.guilds.cache.get(guildIdOverride);
310
+ if (!found) {
311
+ cliLogger.error(`Guild not found: ${guildIdOverride}`);
312
+ void client.destroy();
313
+ process.exit(EXIT_NO_RESTART);
314
+ }
315
+ return found;
316
+ }
317
+ // Try existing channel lookup to find the guild the user already has channels in.
318
+ // This handles multi-guild setups where we want to add to the same guild.
319
+ const db = await getDb();
320
+ const existingChannels = await db.query.channel_directories.findMany({
321
+ where: { channel_type: 'text' },
322
+ orderBy: { created_at: 'desc' },
323
+ columns: { channel_id: true },
324
+ limit: 20,
325
+ });
326
+ // Log available guilds for debugging guild selection issues
327
+ const cachedGuilds = Array.from(client.guilds.cache.values());
328
+ cliLogger.debug(`Guilds in cache (${cachedGuilds.length}): ${cachedGuilds.map((g) => `${g.name} (${g.id})`).join(', ')}`);
329
+ // When multiple guilds are available, find which guild has the most
330
+ // existing channels. The user's main guild will have far more channels
331
+ // than a test/demo guild.
332
+ const guildHits = new Map();
333
+ for (const row of existingChannels) {
334
+ try {
335
+ const ch = await client.channels.fetch(row.channel_id);
336
+ if (ch && !ch.isDMBased()) {
337
+ const entry = guildHits.get(ch.guild.id);
338
+ if (entry) {
339
+ entry.count++;
340
+ }
341
+ else {
342
+ guildHits.set(ch.guild.id, { guild: ch.guild, count: 1 });
343
+ }
344
+ }
345
+ }
346
+ catch {
347
+ // Channel might be in a different guild (gateway proxy rejects) or deleted, skip
348
+ }
349
+ }
350
+ if (guildHits.size > 0) {
351
+ // Pick the guild with the most channels
352
+ const best = Array.from(guildHits.values()).sort((a, b) => b.count - a.count)[0];
353
+ cliLogger.debug(`Guild channel counts: ${Array.from(guildHits.values()).map((e) => `${e.guild.name} (${e.guild.id}): ${e.count}`).join(', ')}`);
354
+ cliLogger.debug(`Selected guild: ${best.guild.name} (${best.guild.id}) with ${best.count} channels`);
355
+ return best.guild;
356
+ }
357
+ cliLogger.debug('Could not resolve guild from existing channels, falling back to cache');
358
+ // If only one guild in cache, use it directly (common case).
359
+ // If multiple guilds, error out and ask the user to specify --guild
360
+ // since we can't determine which one to use.
361
+ if (cachedGuilds.length === 1) {
362
+ return cachedGuilds[0];
363
+ }
364
+ if (cachedGuilds.length > 1) {
365
+ cliLogger.error(`Multiple guilds found. Use --guild to specify which one:\n${cachedGuilds.map((g) => ` ${g.id} ${g.name}`).join('\n')}`);
366
+ void client.destroy();
367
+ process.exit(EXIT_NO_RESTART);
368
+ }
369
+ // Cache empty, try fetching
370
+ const fetched = await client.guilds.fetch();
371
+ if (fetched.size === 1) {
372
+ const firstOAuth2Guild = fetched.first();
373
+ return await client.guilds.fetch(firstOAuth2Guild.id);
374
+ }
375
+ if (fetched.size > 1) {
376
+ cliLogger.error(`Multiple guilds found. Use --guild to specify which one:\n${Array.from(fetched.values()).map((g) => ` ${g.id} ${g.name}`).join('\n')}`);
377
+ void client.destroy();
378
+ process.exit(EXIT_NO_RESTART);
379
+ }
380
+ cliLogger.error('No guild found. Add the bot to a server first.');
381
+ void client.destroy();
382
+ process.exit(EXIT_NO_RESTART);
383
+ }
386
384
  export default cli;
@@ -4,12 +4,16 @@
4
4
  // the prompt is sent with that agent and the session keeps that agent afterwards.
5
5
  import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
6
6
  import crypto from 'node:crypto';
7
- import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, } from '../database.js';
7
+ import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, getChannelWorktreesEnabled, } from '../database.js';
8
8
  import { initializeOpencodeForDirectory } from '../opencode.js';
9
9
  import { resolveTextChannel, resolveWorkingDirectory, getKimakiMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
10
10
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
11
11
  import { createLogger, LogPrefix } from '../logger.js';
12
12
  import { getCurrentModelInfo } from './model.js';
13
+ import { isGitRepositoryRoot } from '../worktrees.js';
14
+ import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage, } from './new-worktree.js';
15
+ import { WORKTREE_PREFIX } from './merge-worktree.js';
16
+ import { store } from '../store.js';
13
17
  const agentLogger = createLogger(LogPrefix.AGENT);
14
18
  const AGENT_CONTEXT_TTL_MS = 10 * 60 * 1000;
15
19
  const pendingAgentContexts = new Map();
@@ -429,22 +433,65 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
429
433
  return;
430
434
  }
431
435
  await command.deferReply();
436
+ // Check if worktrees should be enabled (CLI flag OR channel setting),
437
+ // mirroring the logic in discord-bot.ts message handler.
438
+ const wantsWorktrees = store.getState().useWorktrees ||
439
+ (await getChannelWorktreesEnabled(channel.id));
440
+ const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
441
+ if (wantsWorktrees && !shouldUseWorktrees) {
442
+ agentLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
443
+ }
444
+ const baseThreadName = prompt.slice(0, 80);
445
+ const threadName = shouldUseWorktrees
446
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
447
+ : baseThreadName;
432
448
  const starterMessage = await channel.send({
433
449
  content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
434
450
  flags: SILENT_MESSAGE_FLAGS,
435
451
  });
436
452
  const thread = await starterMessage.startThread({
437
- name: prompt.slice(0, 80),
453
+ name: threadName.slice(0, 80),
438
454
  autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
439
455
  reason: `${resolvedAgentName} agent prompt`,
440
456
  });
441
457
  await thread.members.add(command.user.id);
442
- await command.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`);
458
+ // Create worktree in background if enabled, same as discord-bot.ts
459
+ let worktreePromise;
460
+ if (shouldUseWorktrees) {
461
+ const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50));
462
+ agentLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
463
+ const worktreeStatusMessage = await thread
464
+ .send({
465
+ content: worktreeCreatingMessage(worktreeName),
466
+ flags: SILENT_MESSAGE_FLAGS,
467
+ })
468
+ .catch(() => undefined);
469
+ worktreePromise = createWorktreeInBackground({
470
+ thread,
471
+ starterMessage: worktreeStatusMessage,
472
+ worktreeName,
473
+ projectDirectory,
474
+ rest: command.client.rest,
475
+ });
476
+ }
477
+ const sessionDirectory = await (async () => {
478
+ if (!worktreePromise)
479
+ return projectDirectory;
480
+ const result = await worktreePromise;
481
+ if (result instanceof Error)
482
+ return projectDirectory;
483
+ return result;
484
+ })();
485
+ await command
486
+ .editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`)
487
+ .catch(() => {
488
+ agentLogger.warn('[AGENT] Failed to edit quick-agent reply, continuing session');
489
+ });
443
490
  const runtime = getOrCreateRuntime({
444
491
  threadId: thread.id,
445
492
  thread,
446
493
  projectDirectory,
447
- sdkDirectory: projectDirectory,
494
+ sdkDirectory: sessionDirectory,
448
495
  channelId: channel.id,
449
496
  appId,
450
497
  });
@@ -1,11 +1,14 @@
1
1
  // User-defined OpenCode command handler.
2
2
  // Handles slash commands that map to user-configured commands in opencode.json.
3
- import { ChannelType, MessageFlags, } from 'discord.js';
3
+ import { ChannelType, MessageFlags, ThreadAutoArchiveDuration, } from 'discord.js';
4
4
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
5
5
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger, LogPrefix } from '../logger.js';
7
- import { getChannelDirectory, getThreadSession } from '../database.js';
7
+ import { getChannelDirectory, getChannelWorktreesEnabled, getThreadSession, } from '../database.js';
8
8
  import { store } from '../store.js';
9
+ import { isGitRepositoryRoot } from '../worktrees.js';
10
+ import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage, } from './new-worktree.js';
11
+ import { WORKTREE_PREFIX } from './merge-worktree.js';
9
12
  import fs from 'node:fs';
10
13
  const userCommandLogger = createLogger(LogPrefix.USER_CMD);
11
14
  const DISCORD_MESSAGE_LIMIT = 2000;
@@ -107,23 +110,62 @@ export const handleUserCommand = async ({ command, appId, }) => {
107
110
  }
108
111
  else if (textChannel) {
109
112
  // Running in text channel - create a new thread
113
+ // Check if worktrees should be enabled (CLI flag OR channel setting),
114
+ // mirroring the logic in discord-bot.ts message handler.
115
+ const wantsWorktrees = store.getState().useWorktrees ||
116
+ (await getChannelWorktreesEnabled(textChannel.id));
117
+ const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
118
+ if (wantsWorktrees && !shouldUseWorktrees) {
119
+ userCommandLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
120
+ }
121
+ const baseThreadName = commandInvocation.slice(0, DISCORD_THREAD_NAME_LIMIT);
122
+ const threadName = shouldUseWorktrees
123
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
124
+ : baseThreadName;
110
125
  const starterMessage = await textChannel.send({
111
126
  content: threadOpeningMessage,
112
127
  flags: SILENT_MESSAGE_FLAGS,
113
128
  });
114
129
  const newThread = await starterMessage.startThread({
115
- name: commandInvocation.slice(0, DISCORD_THREAD_NAME_LIMIT),
116
- autoArchiveDuration: 1440,
130
+ name: threadName.slice(0, DISCORD_THREAD_NAME_LIMIT),
131
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
117
132
  reason: `OpenCode command: ${commandName}`,
118
133
  });
119
134
  // Add user to thread so it appears in their sidebar
120
135
  await newThread.members.add(command.user.id);
136
+ // Create worktree in background if enabled, same as discord-bot.ts
137
+ let worktreePromise;
138
+ if (shouldUseWorktrees) {
139
+ const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50));
140
+ userCommandLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
141
+ const worktreeStatusMessage = await newThread
142
+ .send({
143
+ content: worktreeCreatingMessage(worktreeName),
144
+ flags: SILENT_MESSAGE_FLAGS,
145
+ })
146
+ .catch(() => undefined);
147
+ worktreePromise = createWorktreeInBackground({
148
+ thread: newThread,
149
+ starterMessage: worktreeStatusMessage,
150
+ worktreeName,
151
+ projectDirectory,
152
+ rest: command.client.rest,
153
+ });
154
+ }
155
+ const sessionDirectory = await (async () => {
156
+ if (!worktreePromise)
157
+ return projectDirectory;
158
+ const result = await worktreePromise;
159
+ if (result instanceof Error)
160
+ return projectDirectory;
161
+ return result;
162
+ })();
121
163
  await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
122
164
  const runtime = getOrCreateRuntime({
123
165
  threadId: newThread.id,
124
166
  thread: newThread,
125
167
  projectDirectory,
126
- sdkDirectory: projectDirectory,
168
+ sdkDirectory: sessionDirectory,
127
169
  channelId: textChannel.id,
128
170
  appId,
129
171
  });
@@ -173,6 +173,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
173
173
  if (!discordClient) {
174
174
  discordClient = await createDiscordClient();
175
175
  }
176
+ store.setState({ useWorktrees: Boolean(useWorktrees) });
176
177
  let currentAppId = appId;
177
178
  const setupHandlers = async (c) => {
178
179
  discordLogger.log(`Discord bot logged in as ${c.user.tag}`);
@@ -260,9 +261,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
260
261
  discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
261
262
  if (state.attempts >= MAX_RECONNECT_ATTEMPTS) {
262
263
  discordLogger.error(`[GATEWAY] Shard ${shardId} exceeded ${MAX_RECONNECT_ATTEMPTS} reconnect attempts, self-restarting`);
263
- // Self-restart: cleanup then spawn a fresh process. This works whether
264
- // the bin.ts wrapper is present or not (unlike process.exit(1) which
265
- // only restarts when the wrapper is the parent).
264
+ // Self-restart: cleanup, then exit non-zero so the bin.ts wrapper
265
+ // restarts us. Without the wrapper this exits after logging a warning.
266
266
  void selfRestart('gateway-reconnect-limit');
267
267
  }
268
268
  });
@@ -332,15 +332,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
332
332
  return;
333
333
  }
334
334
  }
335
- // Ignore messages that start with a mention of another user (not the bot).
336
- // These are likely users talking to each other, not the bot.
335
+ // Detect messages that start with a mention of another user (not the bot).
336
+ // In channels these are fully ignored. In threads they are added to the
337
+ // session context without triggering the AI (noReply), so the agent sees
338
+ // user-to-user conversation on the next real turn.
337
339
  const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
338
- if (leadingMentionMatch) {
339
- const mentionedUserId = leadingMentionMatch[1];
340
- if (mentionedUserId !== discordClient.user?.id) {
341
- return;
342
- }
343
- }
340
+ const isLeadingMentionToOtherUser = leadingMentionMatch &&
341
+ leadingMentionMatch[1] !== discordClient.user?.id;
344
342
  if (message.partial) {
345
343
  discordLogger.log(`Fetching partial message ${message.id}`);
346
344
  const fetched = await message.fetch()
@@ -354,6 +352,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
354
352
  // When mention mode is enabled, users without Kimaki role can message
355
353
  // without getting a permission error - we just silently ignore.
356
354
  const channel = message.channel;
355
+ // In text channels, messages starting with a mention to another user
356
+ // are fully ignored before any permission or mention-mode checks.
357
+ // This prevents permission-error replies for user-to-user conversation.
358
+ if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
359
+ return;
360
+ }
357
361
  if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
358
362
  const mentionModeEnabled = await getChannelMentionMode(channel.id);
359
363
  if (mentionModeEnabled) {
@@ -435,6 +439,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
435
439
  discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
436
440
  return;
437
441
  }
442
+ // Context-only messages (user-to-user replies) can't be stored without
443
+ // an existing session. Skip early to avoid creating a runtime or running
444
+ // preprocessing for nothing.
445
+ if (isLeadingMentionToOtherUser && !hasExistingSession) {
446
+ return;
447
+ }
438
448
  const parent = thread.parent;
439
449
  let projectDirectory;
440
450
  if (parent) {
@@ -509,7 +519,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
509
519
  const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
510
520
  ? extractBtwSuffix(message.content || '')
511
521
  : null;
512
- if (btwResult?.forceBtw && projectDirectory) {
522
+ if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
513
523
  const result = await forkSessionToBtwThread({
514
524
  sourceThread: thread,
515
525
  projectDirectory,
@@ -559,7 +569,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
559
569
  appId: currentAppId,
560
570
  });
561
571
  // Cancel interactive UI when a real user sends a message.
562
- if (!message.author.bot && !isCliInjectedPrompt) {
572
+ // Context-only messages (user-to-user replies) should not interrupt
573
+ // the active run or dismiss pending UI.
574
+ if (!message.author.bot && !isCliInjectedPrompt && !isLeadingMentionToOtherUser) {
563
575
  cancelPendingActionButtons(thread.id);
564
576
  cancelHtmlActionsForThread(thread.id);
565
577
  const dismissedPermission = await cancelPendingPermission(thread.id);
@@ -594,6 +606,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
594
606
  model: cliInjectedModel,
595
607
  permissions: cliInjectedPermissions,
596
608
  injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
609
+ noReply: isLeadingMentionToOtherUser || undefined,
597
610
  sessionStartSource: sessionStartSource
598
611
  ? {
599
612
  scheduleKind: sessionStartSource.scheduleKind,
@@ -1152,7 +1165,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1152
1165
  discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
1153
1166
  });
1154
1167
  });
1155
- // Self-restart: prefer bin.ts wrapper (keeps Ctrl+C), fall back to detached spawn.
1168
+ // Self-restart: exit with code 1 so the bin.ts wrapper restarts us with
1169
+ // exponential backoff and crash-loop detection. When running without the
1170
+ // wrapper (e.g. `tsx src/cli.ts`), the process just exits and the user
1171
+ // sees the non-zero exit code — they should use `tsx src/bin.ts` instead
1172
+ // for auto-restart support. The previous detached-spawn fallback was
1173
+ // unreliable: process.exit(0) could kill the child before it started,
1174
+ // there was no backoff between restarts, and spawn failures were silent.
1156
1175
  let selfRestarting = false;
1157
1176
  async function selfRestart(reason) {
1158
1177
  if (selfRestarting) {
@@ -1167,20 +1186,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1167
1186
  catch (error) {
1168
1187
  voiceLogger.error(`[${reason}] Error during shutdown:`, error);
1169
1188
  }
1170
- if (process.env.__KIMAKI_CHILD) {
1171
- discordLogger.log('Wrapper detected, exiting for wrapper restart');
1172
- process.exit(1);
1189
+ if (!process.env.__KIMAKI_CHILD) {
1190
+ discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
1173
1191
  }
1174
- const { spawn } = await import('node:child_process');
1175
- const env = { ...process.env };
1176
- delete env.__KIMAKI_CHILD;
1177
- spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
1178
- stdio: 'inherit',
1179
- detached: true,
1180
- cwd: process.cwd(),
1181
- env,
1182
- }).unref();
1183
- process.exit(0);
1192
+ process.exit(1);
1184
1193
  }
1185
1194
  process.on('SIGUSR2', () => {
1186
1195
  discordLogger.log('Received SIGUSR2, restarting after cleanup...');
@@ -590,14 +590,31 @@ export async function resolveWorkingDirectory({ channel, }) {
590
590
  workingDirectory,
591
591
  };
592
592
  }
593
+ // Discord upload size limits per server boost tier (bytes).
594
+ // Bots default to 25 MB; boosted servers raise the ceiling.
595
+ export const DISCORD_DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024;
593
596
  /**
594
597
  * Upload files to a Discord thread/channel in a single message.
595
598
  * Sending all files in one message causes Discord to display images in a grid layout.
599
+ *
600
+ * Files are validated against the Discord upload size limit before reading them
601
+ * into memory. Pass `maxFileSize` if you know the guild's boost tier limit;
602
+ * otherwise the conservative 25 MB bot default is used.
596
603
  */
597
- export async function uploadFilesToDiscord({ threadId, botToken, files, }) {
604
+ export async function uploadFilesToDiscord({ threadId, botToken, files, maxFileSize = DISCORD_DEFAULT_MAX_FILE_SIZE, }) {
598
605
  if (files.length === 0) {
599
606
  return;
600
607
  }
608
+ // Fail fast: check file sizes before reading anything into memory
609
+ const sizeLimit = maxFileSize ?? DISCORD_DEFAULT_MAX_FILE_SIZE;
610
+ for (const file of files) {
611
+ const stat = fs.statSync(file);
612
+ if (stat.size > sizeLimit) {
613
+ const fileMB = (stat.size / 1024 / 1024).toFixed(1);
614
+ const limitMB = (sizeLimit / 1024 / 1024).toFixed(0);
615
+ throw new Error(`File "${path.basename(file)}" is ${fileMB} MB, which exceeds Discord's ${limitMB} MB upload limit`);
616
+ }
617
+ }
601
618
  // Build attachments array for all files
602
619
  const attachments = files.map((file, index) => ({
603
620
  id: index,
@@ -266,6 +266,44 @@ export async function getFileAttachments(message) {
266
266
  return results.filter((r) => r !== null);
267
267
  }
268
268
  const MAX_BASH_COMMAND_INLINE_LENGTH = 100;
269
+ /**
270
+ * Format the inline title for a bash tool part. Handles three cases:
271
+ * 1. Short single-line command → show full command
272
+ * 2. Long/multiline command with description → show description
273
+ * 3. Long/multiline command without description → truncate first line of command
274
+ *
275
+ * The description field was removed from the opencode v2 bash tool schema but
276
+ * kimaki's system prompt instructs models to always send it as an extra field.
277
+ * Case 3 is the fallback when a model omits it.
278
+ */
279
+ export function formatBashToolTitle({ command, description, stateTitle, }) {
280
+ if (!command && !description && !stateTitle)
281
+ return '';
282
+ const isSingleLine = !command.includes('\n');
283
+ // Find first non-empty line to handle commands with leading blank lines
284
+ const firstMeaningfulLine = command
285
+ .split('\n')
286
+ .find((line) => line.trim().length > 0)
287
+ ?.trimStart() ?? '';
288
+ if (command && isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH) {
289
+ return ` _${escapeInlineMarkdown(command)}_`;
290
+ }
291
+ if (description) {
292
+ return ` _${escapeInlineMarkdown(description)}_`;
293
+ }
294
+ if (firstMeaningfulLine.length > 0) {
295
+ const needsTruncation = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH;
296
+ const base = needsTruncation
297
+ ? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH)
298
+ : firstMeaningfulLine;
299
+ // Always add ellipsis when showing a partial command (multiline or length-truncated)
300
+ return ` _${escapeInlineMarkdown(base)}…_`;
301
+ }
302
+ if (stateTitle) {
303
+ return ` _${escapeInlineMarkdown(stateTitle)}_`;
304
+ }
305
+ return '';
306
+ }
269
307
  export function getToolSummaryText(part) {
270
308
  if (part.type !== 'tool')
271
309
  return '';
@@ -449,12 +487,10 @@ export function formatPart(part, prefix) {
449
487
  }
450
488
  const command = part.state.input?.command || '';
451
489
  const description = part.state.input?.description || '';
452
- const isSingleLine = !command.includes('\n');
453
- const toolTitle = isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH
454
- ? ` _${escapeInlineMarkdown(command)}_`
455
- : description
456
- ? ` _${escapeInlineMarkdown(description)}_`
457
- : '';
490
+ const toolTitle = formatBashToolTitle({
491
+ command,
492
+ description,
493
+ });
458
494
  return `┣ ${pfx}bash${toolTitle}`;
459
495
  }
460
496
  const summaryText = getToolSummaryText(part);
@@ -466,16 +502,12 @@ export function formatPart(part, prefix) {
466
502
  else if (part.tool === 'bash') {
467
503
  const command = part.state.input?.command || '';
468
504
  const description = part.state.input?.description || '';
469
- const isSingleLine = !command.includes('\n');
470
- if (isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH) {
471
- toolTitle = `_${escapeInlineMarkdown(command)}_`;
472
- }
473
- else if (description) {
474
- toolTitle = `_${escapeInlineMarkdown(description)}_`;
475
- }
476
- else if (stateTitle) {
477
- toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
478
- }
505
+ const formatted = formatBashToolTitle({
506
+ command,
507
+ description,
508
+ stateTitle,
509
+ });
510
+ toolTitle = formatted.startsWith(' ') ? formatted.slice(1) : formatted;
479
511
  }
480
512
  else if (stateTitle) {
481
513
  toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from 'vitest';
2
- import { formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js';
2
+ import { formatBashToolTitle, formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js';
3
3
  describe('formatPart', () => {
4
4
  test('callout text does not get ⬥ prefix', () => {
5
5
  const part = {
@@ -42,6 +42,44 @@ describe('formatPart', () => {
42
42
  `);
43
43
  });
44
44
  });
45
+ describe('formatBashToolTitle', () => {
46
+ test('short single-line command shown in full', () => {
47
+ expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`);
48
+ });
49
+ test('multiline command without description truncates to first line', () => {
50
+ expect(formatBashToolTitle({ command: 'echo hello\necho world\necho done' })).toMatchInlineSnapshot(`" _echo hello…_"`);
51
+ });
52
+ test('long single-line command is truncated with ellipsis', () => {
53
+ const longCommand = 'a'.repeat(150);
54
+ const result = formatBashToolTitle({ command: longCommand });
55
+ expect(result).toContain('…');
56
+ expect(result.length).toBeLessThan(150);
57
+ });
58
+ test('description is preferred over truncated command when present', () => {
59
+ expect(formatBashToolTitle({
60
+ command: 'echo hello\necho world',
61
+ description: 'Print greeting',
62
+ })).toMatchInlineSnapshot(`" _Print greeting_"`);
63
+ });
64
+ test('stateTitle used as last resort', () => {
65
+ expect(formatBashToolTitle({ command: '', stateTitle: 'Running tests' })).toMatchInlineSnapshot(`" _Running tests_"`);
66
+ });
67
+ test('empty inputs return empty string', () => {
68
+ expect(formatBashToolTitle({ command: '' })).toBe('');
69
+ });
70
+ test('leading blank line skipped, uses first meaningful line', () => {
71
+ expect(formatBashToolTitle({ command: '\npnpm test\npnpm build' })).toMatchInlineSnapshot(`" _pnpm test…_"`);
72
+ });
73
+ test('whitespace-only first line skipped', () => {
74
+ expect(formatBashToolTitle({ command: ' \npnpm test' })).toMatchInlineSnapshot(`" _pnpm test…_"`);
75
+ });
76
+ test('no description field (new opencode) with multiline command', () => {
77
+ // This is the exact scenario that was broken: opencode removed `description`
78
+ // from the bash tool schema, so multiline commands rendered as just "┣ bash"
79
+ const command = 'git diff HEAD~1 --stat && git log --oneline -5';
80
+ expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(`" _git diff HEAD\\~1 --stat && git log --oneline -5…_"`);
81
+ });
82
+ });
45
83
  describe('formatTodoList', () => {
46
84
  test('formats active todo with monospace numbers', () => {
47
85
  const part = {