kimaki 0.19.0 → 0.20.0

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
  });
@@ -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}`);
@@ -332,15 +333,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
332
333
  return;
333
334
  }
334
335
  }
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.
336
+ // Detect messages that start with a mention of another user (not the bot).
337
+ // In channels these are fully ignored. In threads they are added to the
338
+ // session context without triggering the AI (noReply), so the agent sees
339
+ // user-to-user conversation on the next real turn.
337
340
  const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
338
- if (leadingMentionMatch) {
339
- const mentionedUserId = leadingMentionMatch[1];
340
- if (mentionedUserId !== discordClient.user?.id) {
341
- return;
342
- }
343
- }
341
+ const isLeadingMentionToOtherUser = leadingMentionMatch &&
342
+ leadingMentionMatch[1] !== discordClient.user?.id;
344
343
  if (message.partial) {
345
344
  discordLogger.log(`Fetching partial message ${message.id}`);
346
345
  const fetched = await message.fetch()
@@ -354,6 +353,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
354
353
  // When mention mode is enabled, users without Kimaki role can message
355
354
  // without getting a permission error - we just silently ignore.
356
355
  const channel = message.channel;
356
+ // In text channels, messages starting with a mention to another user
357
+ // are fully ignored before any permission or mention-mode checks.
358
+ // This prevents permission-error replies for user-to-user conversation.
359
+ if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
360
+ return;
361
+ }
357
362
  if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
358
363
  const mentionModeEnabled = await getChannelMentionMode(channel.id);
359
364
  if (mentionModeEnabled) {
@@ -435,6 +440,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
435
440
  discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
436
441
  return;
437
442
  }
443
+ // Context-only messages (user-to-user replies) can't be stored without
444
+ // an existing session. Skip early to avoid creating a runtime or running
445
+ // preprocessing for nothing.
446
+ if (isLeadingMentionToOtherUser && !hasExistingSession) {
447
+ return;
448
+ }
438
449
  const parent = thread.parent;
439
450
  let projectDirectory;
440
451
  if (parent) {
@@ -509,7 +520,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
509
520
  const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
510
521
  ? extractBtwSuffix(message.content || '')
511
522
  : null;
512
- if (btwResult?.forceBtw && projectDirectory) {
523
+ if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
513
524
  const result = await forkSessionToBtwThread({
514
525
  sourceThread: thread,
515
526
  projectDirectory,
@@ -559,7 +570,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
559
570
  appId: currentAppId,
560
571
  });
561
572
  // Cancel interactive UI when a real user sends a message.
562
- if (!message.author.bot && !isCliInjectedPrompt) {
573
+ // Context-only messages (user-to-user replies) should not interrupt
574
+ // the active run or dismiss pending UI.
575
+ if (!message.author.bot && !isCliInjectedPrompt && !isLeadingMentionToOtherUser) {
563
576
  cancelPendingActionButtons(thread.id);
564
577
  cancelHtmlActionsForThread(thread.id);
565
578
  const dismissedPermission = await cancelPendingPermission(thread.id);
@@ -594,6 +607,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
594
607
  model: cliInjectedModel,
595
608
  permissions: cliInjectedPermissions,
596
609
  injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
610
+ noReply: isLeadingMentionToOtherUser || undefined,
597
611
  sessionStartSource: sessionStartSource
598
612
  ? {
599
613
  scheduleKind: sessionStartSource.scheduleKind,
@@ -266,6 +266,42 @@ 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 bash tool schema in newer opencode
276
+ * versions, so case 3 is the common path now. Without this fallback, long commands
277
+ * would render as just "┣ bash" with no context.
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 truncated = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH
296
+ ? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH) + '…'
297
+ : firstMeaningfulLine;
298
+ return ` _${escapeInlineMarkdown(truncated)}_`;
299
+ }
300
+ if (stateTitle) {
301
+ return ` _${escapeInlineMarkdown(stateTitle)}_`;
302
+ }
303
+ return '';
304
+ }
269
305
  export function getToolSummaryText(part) {
270
306
  if (part.type !== 'tool')
271
307
  return '';
@@ -449,12 +485,10 @@ export function formatPart(part, prefix) {
449
485
  }
450
486
  const command = part.state.input?.command || '';
451
487
  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
- : '';
488
+ const toolTitle = formatBashToolTitle({
489
+ command,
490
+ description,
491
+ });
458
492
  return `┣ ${pfx}bash${toolTitle}`;
459
493
  }
460
494
  const summaryText = getToolSummaryText(part);
@@ -466,16 +500,12 @@ export function formatPart(part, prefix) {
466
500
  else if (part.tool === 'bash') {
467
501
  const command = part.state.input?.command || '';
468
502
  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
- }
503
+ const formatted = formatBashToolTitle({
504
+ command,
505
+ description,
506
+ stateTitle,
507
+ });
508
+ toolTitle = formatted.startsWith(' ') ? formatted.slice(1) : formatted;
479
509
  }
480
510
  else if (stateTitle) {
481
511
  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 = {
@@ -190,23 +190,23 @@ function getTokenTotal(tokens) {
190
190
  tokens.cache.read +
191
191
  tokens.cache.write);
192
192
  }
193
+ /**
194
+ * Built-in read-only tools that are hidden in default verbosity mode.
195
+ * Any tool NOT in this list is considered "essential" and shown,
196
+ * which means custom tools, MCP tools, and plugin tools are visible by default.
197
+ */
198
+ const HIDDEN_READONLY_TOOLS = [
199
+ 'read',
200
+ 'glob',
201
+ 'grep',
202
+ 'describe-media',
203
+ 'todoread',
204
+ ];
193
205
  /** Check if a tool part is "essential" (shown in text-and-essential-tools mode). */
194
206
  export function isEssentialToolName(toolName) {
195
- const essentialTools = [
196
- 'edit',
197
- 'write',
198
- 'apply_patch',
199
- 'bash',
200
- 'webfetch',
201
- 'websearch',
202
- 'googlesearch',
203
- 'codesearch',
204
- 'task',
205
- 'todowrite',
206
- 'skill',
207
- ];
208
- // Also match any MCP tool that contains these names
209
- return essentialTools.some((name) => {
207
+ // Hide known read-only built-in tools; show everything else
208
+ // (custom tools, MCP tools, plugin tools are visible by default)
209
+ return !HIDDEN_READONLY_TOOLS.some((name) => {
210
210
  return toolName === name || toolName.endsWith(`_${name}`);
211
211
  });
212
212
  }
@@ -2062,6 +2062,15 @@ export class ThreadSessionRuntime {
2062
2062
  skippedBySessionGuard = true;
2063
2063
  return;
2064
2064
  }
2065
+ // Context-only messages (noReply) should not create a new session.
2066
+ // If there is no existing session, silently skip.
2067
+ if (input.noReply) {
2068
+ const existingSessionId = this.state?.sessionId || await getThreadSession(this.thread.id) || undefined;
2069
+ if (!existingSessionId) {
2070
+ logger.log(`[INGRESS] Skipping noReply message for thread ${this.threadId}: no existing session`);
2071
+ return;
2072
+ }
2073
+ }
2065
2074
  // Helper: stop typing and drain queued local messages on error.
2066
2075
  const cleanupOnError = async (errorMessage) => {
2067
2076
  this.stopTyping();
@@ -2269,6 +2278,7 @@ export class ThreadSessionRuntime {
2269
2278
  ...(resolvedAgent ? { agent: resolvedAgent } : {}),
2270
2279
  ...(modelField ? { model: modelField } : {}),
2271
2280
  ...variantField,
2281
+ ...(input.noReply ? { noReply: true } : {}),
2272
2282
  };
2273
2283
  const promptResult = await getClient().session.promptAsync(request)
2274
2284
  .catch((e) => new OpenCodeSdkError({ operation: 'session.promptAsync', cause: e }));
@@ -2284,7 +2294,10 @@ export class ThreadSessionRuntime {
2284
2294
  return;
2285
2295
  }
2286
2296
  logger.log(`[INGRESS] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`);
2287
- this.markQueueDispatchBusy(session.id);
2297
+ // noReply messages don't trigger the agent loop, so don't mark as busy
2298
+ if (!input.noReply) {
2299
+ this.markQueueDispatchBusy(session.id);
2300
+ }
2288
2301
  });
2289
2302
  if (skippedBySessionGuard) {
2290
2303
  return { queued: false };
@@ -2426,9 +2439,17 @@ export class ThreadSessionRuntime {
2426
2439
  // Route with the resolved mode through normal paths.
2427
2440
  // Await the enqueue so session state (ensureSession, setThreadSession)
2428
2441
  // is persisted before the next message's preprocessing reads it.
2429
- const enqueueResult = resolvedInput.mode === 'local-queue' || resolvedInput.command
2430
- ? await this.enqueueViaLocalQueue(resolvedInput)
2431
- : await this.submitViaOpencodeQueue(resolvedInput);
2442
+ // noReply messages always go through the opencode path so the flag
2443
+ // reaches promptAsync; local queue doesn't support noReply.
2444
+ const enqueueResult = resolvedInput.noReply
2445
+ ? await this.submitViaOpencodeQueue({
2446
+ ...resolvedInput,
2447
+ mode: 'opencode',
2448
+ command: undefined,
2449
+ })
2450
+ : (resolvedInput.mode === 'local-queue' || resolvedInput.command)
2451
+ ? await this.enqueueViaLocalQueue(resolvedInput)
2452
+ : await this.submitViaOpencodeQueue(resolvedInput);
2432
2453
  resolveOuter(enqueueResult);
2433
2454
  }
2434
2455
  catch (err) {
package/dist/store.js CHANGED
@@ -14,6 +14,7 @@ export const store = createStore(() => ({
14
14
  allowedMentions: ['users'],
15
15
  allowAllUsers: false,
16
16
  permissionTimeoutMs: 10 * 60 * 1000,
17
+ useWorktrees: false,
17
18
  autoUpgradeEnabled: true,
18
19
  syncEnabled: true,
19
20
  discordBaseUrl: 'https://discord.com',
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.19.0",
5
+ "version": "0.20.0",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -25,8 +25,8 @@
25
25
  "tsx": "^4.20.5",
26
26
  "undici": "^8.0.2",
27
27
  "discord-digital-twin": "^0.1.0",
28
- "opencode-cached-provider": "^0.0.1",
29
28
  "opencode-deterministic-provider": "^0.0.1",
29
+ "opencode-cached-provider": "^0.0.1",
30
30
  "db": "^0.0.0"
31
31
  },
32
32
  "dependencies": {
@@ -62,9 +62,9 @@
62
62
  "yaml": "^2.8.3",
63
63
  "zod": "^4.3.6",
64
64
  "zustand": "^5.0.11",
65
- "errore": "^0.14.1",
66
65
  "libsqlproxy": "^0.1.0",
67
66
  "opencode-injection-guard": "^0.2.1",
67
+ "errore": "^0.14.1",
68
68
  "traforo": "^0.7.1"
69
69
  },
70
70
  "optionalDependencies": {
@@ -178,6 +178,23 @@ import { openInBrowser } from 'goke'
178
178
  await openInBrowser('https://example.com/dashboard')
179
179
  ```
180
180
 
181
+ ## Command Descriptions and Examples
182
+
183
+ **Use backtick formatting in descriptions** for flags and command references (e.g. `` `--status` ``, `` `mycli deploy` ``). Plain text flag names won't render as code in generated docs.
184
+
185
+ **Use `.example()` for usage examples, not the description string.** `generateDocs()` auto-wraps `.example()` strings in fenced `` ```sh `` code blocks. Examples in the description render as plain text without syntax highlighting.
186
+
187
+ ```ts
188
+ cli.command(
189
+ 'query <sql>',
190
+ dedent`
191
+ Run a SQL query. Add \`--json\` for the raw JSON envelope.
192
+ `,
193
+ )
194
+ .example('mycli query "SELECT * FROM users" -p my-app')
195
+ .example('mycli query "SELECT * FROM users FORMAT CSV" > out.csv')
196
+ ```
197
+
181
198
  ## Command Naming Conventions
182
199
 
183
200
  **ALWAYS read existing commands before adding a new one.** Scan the CLI for option names, verbs, and noun patterns already in use. New commands must stay consistent with what exists.