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.
@@ -43,6 +43,7 @@ When the user asks about a specific workflow, fetch the matching page directly:
43
43
  - **Node deploy**: https://holocron.so/docs/deploy/node.md
44
44
  - **Holocron deploy**: https://holocron.so/docs/deploy/holocron.md
45
45
  - **AI assistant docs**: https://holocron.so/docs/ai/assistant.md
46
+ - **Bleed**: https://holocron.so/docs/customize/bleed.md
46
47
 
47
48
  ```bash
48
49
  curl -fsSL https://holocron.so/docs/quickstart.md
@@ -326,6 +327,36 @@ sidebarTitle: Auth Providers
326
327
  below the title and in search engine snippets. Write a single sentence that
327
328
  summarizes the page content. Longer descriptions get truncated with an ellipsis.
328
329
 
330
+ ## Page titles (MUST follow)
331
+
332
+ Holocron renders the browser `<title>` as `{page title} — {site name}`. Two
333
+ frontmatter fields control what appears where:
334
+
335
+ - **`title`** — browser tab, Google results, OG image, page heading. Write for SEO.
336
+ - **`sidebarTitle`** — sidebar navigation only. Write for scannability (short label).
337
+
338
+ **Always set both.** `title` is the full descriptive phrase; `sidebarTitle` is
339
+ the short sidebar label. Without `sidebarTitle`, the full title shows in the
340
+ sidebar and often wraps or looks verbose.
341
+
342
+ ```mdx
343
+ ---
344
+ title: Open-source browser automation for AI agents
345
+ sidebarTitle: Home
346
+ description: Automate any browser with a simple TypeScript API.
347
+ ---
348
+ ```
349
+
350
+ Browser tab: `Open-source browser automation for AI agents — Playwriter`
351
+ Sidebar: `Home`
352
+
353
+ **Rules:**
354
+
355
+ - **Never set `title` identical to the site name.** `Playwriter — Playwriter` is redundant.
356
+ - **Every `title` MUST be descriptive, not a generic label.** `Introduction`, `Getting Started`, `Overview` say nothing in a browser tab. Write what the page covers.
357
+ - **Every `title` MUST be unique across the site.**
358
+ - **`index.mdx` MUST use `sidebarTitle: Home`** (or `Overview`) and a descriptive `title`.
359
+
329
360
  ## Custom entry (mounting docs inside a Spiceflow app)
330
361
 
331
362
  When the site mounts Holocron inside an existing **Spiceflow** app (the `entry`
@@ -416,6 +447,12 @@ below the main content to accommodate the aside height. Keep aside callouts
416
447
  short, and only place them in sections that have at least a few paragraphs of
417
448
  body text.
418
449
 
450
+ ## Moving or renaming a page
451
+
452
+ When moving or renaming a page, **always add a redirect** from the old slug to
453
+ the new one so existing links and bookmarks don't break. Update internal links
454
+ in other pages and the slug in `docs.json` navigation to match.
455
+
419
456
  ## New pages and navigation
420
457
 
421
458
  After creating a new `.mdx` or `.md` page, add its slug to `docs.jsonc`
@@ -569,7 +606,7 @@ Use ASCII diagrams frequently in MDX pages. Always use the **`diagram`** languag
569
606
  - Never exceed 94 characters per line.
570
607
  - Prefer a **varied, organic layout**. Mix plain text labels, boxes for major components, and directional arrows.
571
608
  - All connections must use **directional arrows**. Never use plain lines without an arrowhead.
572
- - Verify alignment by counting characters precisely.
609
+ - Verify alignment by running `npx -y @holocron.so/cli diagrams fix <file>` (see dedicated section below).
573
610
 
574
611
  ````mdx
575
612
  ```diagram
@@ -590,6 +627,23 @@ docs.jsonc ───►│ Vite Plugin │──────► Build Output
590
627
  ```
591
628
  ````
592
629
 
630
+ ## Always run `diagrams fix` after editing diagrams
631
+
632
+ LLMs cannot count characters reliably. Every time you create or edit a diagram in an MDX page, you **must** run the alignment fixer before committing. This is not optional.
633
+
634
+ ```bash
635
+ npx -y @holocron.so/cli diagrams fix path/to/file.mdx
636
+ ```
637
+
638
+ The command:
639
+ - Fixes box alignment (padding, border widths, junctions) in-place
640
+ - Reports how many lines were changed
641
+ - Exits with code 1 if any lines exceed max width (94 cols by default)
642
+
643
+ If the output says "No changes", the diagram was already correct. If it reports width violations, shorten the offending lines manually and re-run.
644
+
645
+ **Do not skip this step.** Even if the diagram looks correct in your editor, run the command. Off-by-one padding errors are invisible to LLMs but obvious to humans in monospace fonts.
646
+
593
647
  ## OpenAPI summaries
594
648
 
595
649
  OpenAPI operation `summary` fields become sidebar titles. The sidebar is only
@@ -601,6 +655,15 @@ characters. Use the same concise style as page `sidebarTitle` fields.
601
655
  - Never repeat the tag/group name in the summary. If the tag is `Deploy`, the summary should not start with "Deploy".
602
656
  - If the summary reads well as a sidebar label at 230px, it is short enough.
603
657
 
658
+ ## Bleed — extending content past the prose column
659
+
660
+ Always wrap **YouTube embeds, videos, and large images** in `<div className='bleed'>`
661
+ so they extend into both page margins instead of sitting at narrow prose width.
662
+ `<Frame>` also accepts `className='bleed'`. Containers like Callout, Card, and
663
+ Accordion apply `no-bleed` automatically to keep descendants inside their frame.
664
+
665
+ See https://holocron.so/docs/customize/bleed.md for the full reference.
666
+
604
667
  ## Agent rules
605
668
 
606
669
  - **Always place MDX pages inside the Holocron `pagesDir`.** Before creating or
@@ -614,5 +677,7 @@ characters. Use the same concise style as page `sidebarTitle` fields.
614
677
  - All ASCII diagrams in MDX pages must use the `diagram` language hint on the
615
678
  code fence (` ```diagram `). This renders them with proper styling on the
616
679
  website instead of plain monospace.
680
+ - After creating or editing any diagram, run `npx -y @holocron.so/cli diagrams fix <file>`
681
+ to auto-fix alignment. LLMs cannot count characters reliably.
617
682
  - Never pipe curl or `--help` output through `head`, `tail`, or any truncation
618
683
  command. Always read the full output.
@@ -47,3 +47,16 @@ The README and `zele --help` output are the source of truth for commands, option
47
47
  ```
48
48
  5. **Google-only features** (labels, Gmail filters, `zele cal *`, full profile) fail on IMAP accounts with a clear error. Check `zele whoami` output for account type before using them.
49
49
  6. **Headless Google login** requires a tmux wrapper because `zele login` is interactive. See the README "Remote / headless login" section for the exact pattern.
50
+ 7. **Waiting for emails** with `zele mail watch`. It polls for new emails matching a filter and exits as soon as one arrives. Use this to wait for replies, verification codes, or any expected email:
51
+ ```bash
52
+ # wait for a reply from alice (no timeout, blocks until match)
53
+ zele mail watch --filter "is:unread from:alice@example.com"
54
+
55
+ # wait for a verification code with a 5-minute timeout
56
+ zele mail watch --filter "is:unread subject:verification" --timeout 300
57
+
58
+ # send an email then wait for the reply
59
+ zele mail send --to bob@example.com --subject "Question" --body "Hey, can you check this?"
60
+ zele mail watch --filter "is:unread from:bob@example.com subject:Re:Question" --timeout 600
61
+ ```
62
+ If the matched email wasn't the expected one, call `zele mail watch` again with a more specific filter. Exit code 0 means a match was found, exit code 1 means timeout.
@@ -5,7 +5,7 @@ import { note } from '@clack/prompts'
5
5
  import YAML from 'yaml'
6
6
  import * as errore from 'errore'
7
7
  import type { OpencodeClient, Event as OpenCodeEvent } from '@opencode-ai/sdk/v2'
8
- import { Events, ActivityType, type PresenceStatusData, type Guild, Routes } from 'discord.js'
8
+ import { Events, ActivityType, type PresenceStatusData, type Guild, type Client, Routes } from 'discord.js'
9
9
  import path from 'node:path'
10
10
  import fs from 'node:fs'
11
11
  import { fileURLToPath } from 'node:url'
@@ -98,74 +98,7 @@ cli
98
98
 
99
99
  cliLogger.log('Finding guild...')
100
100
 
101
- // Find guild
102
- let guild: Guild
103
- if (options.guild) {
104
- const guildId = String(options.guild)
105
- const foundGuild = client.guilds.cache.get(guildId)
106
- if (!foundGuild) {
107
- cliLogger.log('Guild not found')
108
- cliLogger.error(`Guild not found: ${guildId}`)
109
- void client.destroy()
110
- process.exit(EXIT_NO_RESTART)
111
- }
112
- guild = foundGuild
113
- } else {
114
- const existingChannelId = await (await getDb()).query.channel_directories.findFirst({
115
- where: { channel_type: 'text' },
116
- orderBy: { created_at: 'desc' },
117
- columns: { channel_id: true },
118
- }).then((row) => row?.channel_id)
119
-
120
- if (existingChannelId) {
121
- try {
122
- const ch = await client.channels.fetch(existingChannelId)
123
- if (ch && !ch.isDMBased()) {
124
- guild = ch.guild
125
- } else {
126
- throw new Error('Channel has no guild')
127
- }
128
- } catch (error) {
129
- cliLogger.debug(
130
- 'Failed to fetch existing channel while selecting guild:',
131
- error instanceof Error ? error.stack : String(error),
132
- )
133
- let firstGuild = client.guilds.cache.first()
134
- if (!firstGuild) {
135
- // Cache might be empty, try fetching guilds from API
136
- const fetched = await client.guilds.fetch()
137
- const firstOAuth2Guild = fetched.first()
138
- if (firstOAuth2Guild) {
139
- firstGuild = await client.guilds.fetch(firstOAuth2Guild.id)
140
- }
141
- }
142
- if (!firstGuild) {
143
- cliLogger.log('No guild found')
144
- cliLogger.error('No guild found. Add the bot to a server first.')
145
- void client.destroy()
146
- process.exit(EXIT_NO_RESTART)
147
- }
148
- guild = firstGuild
149
- }
150
- } else {
151
- let firstGuild = client.guilds.cache.first()
152
- if (!firstGuild) {
153
- // Cache might be empty, try fetching guilds from API
154
- const fetched = await client.guilds.fetch()
155
- const firstOAuth2Guild = fetched.first()
156
- if (firstOAuth2Guild) {
157
- firstGuild = await client.guilds.fetch(firstOAuth2Guild.id)
158
- }
159
- }
160
- if (!firstGuild) {
161
- cliLogger.log('No guild found')
162
- cliLogger.error('No guild found. Add the bot to a server first.')
163
- void client.destroy()
164
- process.exit(EXIT_NO_RESTART)
165
- }
166
- guild = firstGuild
167
- }
168
- }
101
+ const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild })
169
102
 
170
103
  // Check if channel already exists in this guild
171
104
  cliLogger.log('Checking for existing channel...')
@@ -466,24 +399,7 @@ cli
466
399
  client.login(botToken).catch(reject)
467
400
  })
468
401
 
469
- let guild: Guild
470
- if (options.guild) {
471
- const found = client.guilds.cache.get(options.guild)
472
- if (!found) {
473
- cliLogger.error(`Guild not found: ${options.guild}`)
474
- void client.destroy()
475
- process.exit(EXIT_NO_RESTART)
476
- }
477
- guild = found
478
- } else {
479
- const first = client.guilds.cache.first()
480
- if (!first) {
481
- cliLogger.error('No guild found. Add the bot to a server first.')
482
- void client.destroy()
483
- process.exit(EXIT_NO_RESTART)
484
- }
485
- guild = first
486
- }
402
+ const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild })
487
403
 
488
404
  const { textChannelId, channelName } = await createProjectChannels({
489
405
  guild,
@@ -505,4 +421,100 @@ cli
505
421
  })
506
422
 
507
423
 
424
+ // Resolve the guild for project add/create commands. In gateway mode the
425
+ // guild cache only contains authorized guilds, so picking from cache is safe.
426
+ // The old approach fetched an existing channel to infer the guild, but that
427
+ // breaks when the channel belongs to a different guild (e.g. old self-hosted
428
+ // bot channels) and the gateway proxy rejects the REST call. This led to a
429
+ // non-deterministic fallback that picked the wrong guild.
430
+ async function resolveGuildForProjectCommand({ client, guildIdOverride }: { client: Client; guildIdOverride?: string }): Promise<Guild> {
431
+ if (guildIdOverride) {
432
+ const found = client.guilds.cache.get(guildIdOverride)
433
+ if (!found) {
434
+ cliLogger.error(`Guild not found: ${guildIdOverride}`)
435
+ void client.destroy()
436
+ process.exit(EXIT_NO_RESTART)
437
+ }
438
+ return found
439
+ }
440
+
441
+ // Try existing channel lookup to find the guild the user already has channels in.
442
+ // This handles multi-guild setups where we want to add to the same guild.
443
+ const db = await getDb()
444
+ const existingChannels = await db.query.channel_directories.findMany({
445
+ where: { channel_type: 'text' },
446
+ orderBy: { created_at: 'desc' },
447
+ columns: { channel_id: true },
448
+ limit: 20,
449
+ })
450
+
451
+ // Log available guilds for debugging guild selection issues
452
+ const cachedGuilds = Array.from(client.guilds.cache.values())
453
+ cliLogger.debug(`Guilds in cache (${cachedGuilds.length}): ${cachedGuilds.map((g) => `${g.name} (${g.id})`).join(', ')}`)
454
+
455
+ // When multiple guilds are available, find which guild has the most
456
+ // existing channels. The user's main guild will have far more channels
457
+ // than a test/demo guild.
458
+ const guildHits = new Map<string, { guild: Guild; count: number }>()
459
+ for (const row of existingChannels) {
460
+ try {
461
+ const ch = await client.channels.fetch(row.channel_id)
462
+ if (ch && !ch.isDMBased()) {
463
+ const entry = guildHits.get(ch.guild.id)
464
+ if (entry) {
465
+ entry.count++
466
+ } else {
467
+ guildHits.set(ch.guild.id, { guild: ch.guild, count: 1 })
468
+ }
469
+ }
470
+ } catch {
471
+ // Channel might be in a different guild (gateway proxy rejects) or deleted, skip
472
+ }
473
+ }
474
+
475
+ if (guildHits.size > 0) {
476
+ // Pick the guild with the most channels
477
+ const best = Array.from(guildHits.values()).sort((a, b) => b.count - a.count)[0]!
478
+ cliLogger.debug(
479
+ `Guild channel counts: ${Array.from(guildHits.values()).map((e) => `${e.guild.name} (${e.guild.id}): ${e.count}`).join(', ')}`,
480
+ )
481
+ cliLogger.debug(`Selected guild: ${best.guild.name} (${best.guild.id}) with ${best.count} channels`)
482
+ return best.guild
483
+ }
484
+
485
+ cliLogger.debug('Could not resolve guild from existing channels, falling back to cache')
486
+
487
+ // If only one guild in cache, use it directly (common case).
488
+ // If multiple guilds, error out and ask the user to specify --guild
489
+ // since we can't determine which one to use.
490
+ if (cachedGuilds.length === 1) {
491
+ return cachedGuilds[0]!
492
+ }
493
+ if (cachedGuilds.length > 1) {
494
+ cliLogger.error(
495
+ `Multiple guilds found. Use --guild to specify which one:\n${cachedGuilds.map((g) => ` ${g.id} ${g.name}`).join('\n')}`,
496
+ )
497
+ void client.destroy()
498
+ process.exit(EXIT_NO_RESTART)
499
+ }
500
+
501
+ // Cache empty, try fetching
502
+ const fetched = await client.guilds.fetch()
503
+ if (fetched.size === 1) {
504
+ const firstOAuth2Guild = fetched.first()!
505
+ return await client.guilds.fetch(firstOAuth2Guild.id)
506
+ }
507
+ if (fetched.size > 1) {
508
+ cliLogger.error(
509
+ `Multiple guilds found. Use --guild to specify which one:\n${Array.from(fetched.values()).map((g) => ` ${g.id} ${g.name}`).join('\n')}`,
510
+ )
511
+ void client.destroy()
512
+ process.exit(EXIT_NO_RESTART)
513
+ }
514
+
515
+ cliLogger.error('No guild found. Add the bot to a server first.')
516
+ void client.destroy()
517
+ process.exit(EXIT_NO_RESTART)
518
+ }
519
+
508
520
  export default cli
@@ -21,6 +21,7 @@ import {
21
21
  getThreadSession,
22
22
  getSessionAgent,
23
23
  getChannelAgent,
24
+ getChannelWorktreesEnabled,
24
25
  } from '../database.js'
25
26
  import { initializeOpencodeForDirectory } from '../opencode.js'
26
27
  import {
@@ -32,6 +33,14 @@ import {
32
33
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
33
34
  import { createLogger, LogPrefix } from '../logger.js'
34
35
  import { getCurrentModelInfo } from './model.js'
36
+ import { isGitRepositoryRoot } from '../worktrees.js'
37
+ import {
38
+ formatAutoWorktreeName,
39
+ createWorktreeInBackground,
40
+ worktreeCreatingMessage,
41
+ } from './new-worktree.js'
42
+ import { WORKTREE_PREFIX } from './merge-worktree.js'
43
+ import { store } from '../store.js'
35
44
 
36
45
  const agentLogger = createLogger(LogPrefix.AGENT)
37
46
 
@@ -597,26 +606,78 @@ async function handleQuickAgentWithPrompt({
597
606
 
598
607
  await command.deferReply()
599
608
 
609
+ // Check if worktrees should be enabled (CLI flag OR channel setting),
610
+ // mirroring the logic in discord-bot.ts message handler.
611
+ const wantsWorktrees =
612
+ store.getState().useWorktrees ||
613
+ (await getChannelWorktreesEnabled(channel.id))
614
+ const shouldUseWorktrees =
615
+ wantsWorktrees && (await isGitRepositoryRoot(projectDirectory))
616
+
617
+ if (wantsWorktrees && !shouldUseWorktrees) {
618
+ agentLogger.warn(
619
+ `[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`,
620
+ )
621
+ }
622
+
623
+ const baseThreadName = prompt.slice(0, 80)
624
+ const threadName = shouldUseWorktrees
625
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
626
+ : baseThreadName
627
+
600
628
  const starterMessage = await channel.send({
601
629
  content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
602
630
  flags: SILENT_MESSAGE_FLAGS,
603
631
  })
604
632
 
605
633
  const thread = await starterMessage.startThread({
606
- name: prompt.slice(0, 80),
634
+ name: threadName.slice(0, 80),
607
635
  autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
608
636
  reason: `${resolvedAgentName} agent prompt`,
609
637
  })
610
638
 
611
639
  await thread.members.add(command.user.id)
612
640
 
613
- await command.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`)
641
+ // Create worktree in background if enabled, same as discord-bot.ts
642
+ let worktreePromise: Promise<string | Error> | undefined
643
+ if (shouldUseWorktrees) {
644
+ const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50))
645
+ agentLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`)
646
+
647
+ const worktreeStatusMessage = await thread
648
+ .send({
649
+ content: worktreeCreatingMessage(worktreeName),
650
+ flags: SILENT_MESSAGE_FLAGS,
651
+ })
652
+ .catch(() => undefined)
653
+
654
+ worktreePromise = createWorktreeInBackground({
655
+ thread,
656
+ starterMessage: worktreeStatusMessage,
657
+ worktreeName,
658
+ projectDirectory,
659
+ rest: command.client.rest,
660
+ })
661
+ }
662
+
663
+ const sessionDirectory = await (async () => {
664
+ if (!worktreePromise) return projectDirectory
665
+ const result = await worktreePromise
666
+ if (result instanceof Error) return projectDirectory
667
+ return result
668
+ })()
669
+
670
+ await command
671
+ .editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`)
672
+ .catch(() => {
673
+ agentLogger.warn('[AGENT] Failed to edit quick-agent reply, continuing session')
674
+ })
614
675
 
615
676
  const runtime = getOrCreateRuntime({
616
677
  threadId: thread.id,
617
678
  thread,
618
679
  projectDirectory,
619
- sdkDirectory: projectDirectory,
680
+ sdkDirectory: sessionDirectory,
620
681
  channelId: channel.id,
621
682
  appId,
622
683
  })
@@ -297,6 +297,8 @@ export async function startDiscordBot({
297
297
  discordClient = await createDiscordClient()
298
298
  }
299
299
 
300
+ store.setState({ useWorktrees: Boolean(useWorktrees) })
301
+
300
302
  let currentAppId: string | undefined = appId
301
303
 
302
304
  const setupHandlers = async (c: Client<true>) => {
@@ -503,15 +505,14 @@ export async function startDiscordBot({
503
505
  }
504
506
  }
505
507
 
506
- // Ignore messages that start with a mention of another user (not the bot).
507
- // These are likely users talking to each other, not the bot.
508
+ // Detect messages that start with a mention of another user (not the bot).
509
+ // In channels these are fully ignored. In threads they are added to the
510
+ // session context without triggering the AI (noReply), so the agent sees
511
+ // user-to-user conversation on the next real turn.
508
512
  const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/)
509
- if (leadingMentionMatch) {
510
- const mentionedUserId = leadingMentionMatch[1]
511
- if (mentionedUserId !== discordClient.user?.id) {
512
- return
513
- }
514
- }
513
+ const isLeadingMentionToOtherUser =
514
+ leadingMentionMatch &&
515
+ leadingMentionMatch[1] !== discordClient.user?.id
515
516
 
516
517
  if (message.partial) {
517
518
  discordLogger.log(`Fetching partial message ${message.id}`)
@@ -530,6 +531,14 @@ export async function startDiscordBot({
530
531
  // When mention mode is enabled, users without Kimaki role can message
531
532
  // without getting a permission error - we just silently ignore.
532
533
  const channel = message.channel
534
+
535
+ // In text channels, messages starting with a mention to another user
536
+ // are fully ignored before any permission or mention-mode checks.
537
+ // This prevents permission-error replies for user-to-user conversation.
538
+ if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
539
+ return
540
+ }
541
+
533
542
  if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
534
543
  const mentionModeEnabled = await getChannelMentionMode(channel.id)
535
544
  if (mentionModeEnabled) {
@@ -629,6 +638,13 @@ export async function startDiscordBot({
629
638
  return
630
639
  }
631
640
 
641
+ // Context-only messages (user-to-user replies) can't be stored without
642
+ // an existing session. Skip early to avoid creating a runtime or running
643
+ // preprocessing for nothing.
644
+ if (isLeadingMentionToOtherUser && !hasExistingSession) {
645
+ return
646
+ }
647
+
632
648
  const parent = thread.parent as TextChannel | null
633
649
  let projectDirectory: string | undefined
634
650
  if (parent) {
@@ -713,7 +729,7 @@ export async function startDiscordBot({
713
729
  projectDirectory && worktreeInfo?.status !== 'pending'
714
730
  ? extractBtwSuffix(message.content || '')
715
731
  : null
716
- if (btwResult?.forceBtw && projectDirectory) {
732
+ if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
717
733
  const result = await forkSessionToBtwThread({
718
734
  sourceThread: thread,
719
735
  projectDirectory,
@@ -775,7 +791,9 @@ export async function startDiscordBot({
775
791
  })
776
792
 
777
793
  // Cancel interactive UI when a real user sends a message.
778
- if (!message.author.bot && !isCliInjectedPrompt) {
794
+ // Context-only messages (user-to-user replies) should not interrupt
795
+ // the active run or dismiss pending UI.
796
+ if (!message.author.bot && !isCliInjectedPrompt && !isLeadingMentionToOtherUser) {
779
797
  cancelPendingActionButtons(thread.id)
780
798
  cancelHtmlActionsForThread(thread.id)
781
799
  const dismissedPermission = await cancelPendingPermission(thread.id)
@@ -812,6 +830,7 @@ export async function startDiscordBot({
812
830
  model: cliInjectedModel,
813
831
  permissions: cliInjectedPermissions,
814
832
  injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
833
+ noReply: isLeadingMentionToOtherUser || undefined,
815
834
  sessionStartSource: sessionStartSource
816
835
  ? {
817
836
  scheduleKind: sessionStartSource.scheduleKind,
@@ -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
  import type { Collection, Embed, Message, MessageSnapshot, Poll } from 'discord.js'
4
4
  import type { Part } from '@opencode-ai/sdk/v2'
5
5
 
@@ -48,6 +48,65 @@ describe('formatPart', () => {
48
48
  })
49
49
  })
50
50
 
51
+ describe('formatBashToolTitle', () => {
52
+ test('short single-line command shown in full', () => {
53
+ expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`)
54
+ })
55
+
56
+ test('multiline command without description truncates to first line', () => {
57
+ expect(
58
+ formatBashToolTitle({ command: 'echo hello\necho world\necho done' }),
59
+ ).toMatchInlineSnapshot(`" _echo hello_"`)
60
+ })
61
+
62
+ test('long single-line command is truncated with ellipsis', () => {
63
+ const longCommand = 'a'.repeat(150)
64
+ const result = formatBashToolTitle({ command: longCommand })
65
+ expect(result).toContain('…')
66
+ expect(result.length).toBeLessThan(150)
67
+ })
68
+
69
+ test('description is preferred over truncated command when present', () => {
70
+ expect(
71
+ formatBashToolTitle({
72
+ command: 'echo hello\necho world',
73
+ description: 'Print greeting',
74
+ }),
75
+ ).toMatchInlineSnapshot(`" _Print greeting_"`)
76
+ })
77
+
78
+ test('stateTitle used as last resort', () => {
79
+ expect(
80
+ formatBashToolTitle({ command: '', stateTitle: 'Running tests' }),
81
+ ).toMatchInlineSnapshot(`" _Running tests_"`)
82
+ })
83
+
84
+ test('empty inputs return empty string', () => {
85
+ expect(formatBashToolTitle({ command: '' })).toBe('')
86
+ })
87
+
88
+ test('leading blank line skipped, uses first meaningful line', () => {
89
+ expect(
90
+ formatBashToolTitle({ command: '\npnpm test\npnpm build' }),
91
+ ).toMatchInlineSnapshot(`" _pnpm test_"`)
92
+ })
93
+
94
+ test('whitespace-only first line skipped', () => {
95
+ expect(
96
+ formatBashToolTitle({ command: ' \npnpm test' }),
97
+ ).toMatchInlineSnapshot(`" _pnpm test_"`)
98
+ })
99
+
100
+ test('no description field (new opencode) with multiline command', () => {
101
+ // This is the exact scenario that was broken: opencode removed `description`
102
+ // from the bash tool schema, so multiline commands rendered as just "┣ bash"
103
+ const command = 'git diff HEAD~1 --stat && git log --oneline -5'
104
+ expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(
105
+ `" _git diff HEAD\\~1 --stat && git log --oneline -5_"`,
106
+ )
107
+ })
108
+ })
109
+
51
110
  describe('formatTodoList', () => {
52
111
  test('formats active todo with monospace numbers', () => {
53
112
  const part: Part = {