kimaki 0.18.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.
@@ -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
@@ -1,4 +1,8 @@
1
1
  // Terminal send command for creating Discord threads and scheduling prompts.
2
+ // Designed to work in CI/headless environments with just KIMAKI_BOT_TOKEN.
3
+ // The local SQLite database (channel_directories) is NOT required for the basic
4
+ // flow: post message → create thread → remote bot picks it up. The local project
5
+ // directory mapping is only needed for --send-at, --wait, and --cwd.
2
6
  import { goke } from 'goke'
3
7
  import { z } from 'zod'
4
8
  import { note } from '@clack/prompts'
@@ -395,10 +399,20 @@ cli
395
399
  throw new Error(`Thread has no parent channel: ${targetThreadId}`)
396
400
  }
397
401
 
402
+ // channelConfig is optional: in CI/headless environments the local DB
403
+ // has no channel_directories rows because the bot hasn't synced yet.
404
+ // The running bot on the other end resolves the directory from its own DB.
405
+ // We only require it for features that genuinely need a local directory
406
+ // (scheduled tasks and --wait).
398
407
  const channelConfig = await getChannelDirectory(threadData.parent_id)
399
- if (!channelConfig) {
408
+
409
+ // Guard early: fail before sending the message if a feature that
410
+ // needs local project directory mapping is requested.
411
+ if (!channelConfig && (parsedSchedule || options.wait)) {
412
+ const flag = parsedSchedule ? '--send-at' : '--wait'
400
413
  throw new Error(
401
- 'Thread parent channel is not configured with a project directory',
414
+ 'Thread parent channel is not configured with a project directory. ' +
415
+ `${flag} requires a local project mapping. Run the bot first to sync channel data.`,
402
416
  )
403
417
  }
404
418
 
@@ -425,7 +439,8 @@ cli
425
439
  channelId: threadData.parent_id,
426
440
  threadId: targetThreadId,
427
441
  sessionId: sessionId || undefined,
428
- projectDirectory: channelConfig.directory,
442
+ // channelConfig is guaranteed: early guard threw if missing with --send-at
443
+ projectDirectory: channelConfig!.directory,
429
444
  })
430
445
 
431
446
  const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`
@@ -475,10 +490,12 @@ cli
475
490
  process.stdout.write(`${threadUrl}\n`)
476
491
 
477
492
  if (options.wait) {
493
+ // channelConfig is guaranteed here: early guard above already
494
+ // threw if channelConfig is missing when --wait is used.
478
495
  const { waitAndOutputSession } = await import('../wait-session.js')
479
496
  await waitAndOutputSession({
480
497
  threadId: targetThreadId,
481
- projectDirectory: channelConfig.directory,
498
+ projectDirectory: channelConfig!.directory,
482
499
  waitStartedAtMs,
483
500
  })
484
501
  }
@@ -500,22 +517,29 @@ cli
500
517
  guild_id: string
501
518
  }
502
519
 
520
+ // channelConfig is optional: in CI/headless environments the local DB
521
+ // has no channel_directories rows because the bot hasn't synced yet.
522
+ // The running bot on the other end resolves the directory from its own DB.
523
+ // We only require it for features that genuinely need a local directory
524
+ // (--send-at, --wait, --cwd).
503
525
  const channelConfig = await getChannelDirectory(channelData.id)
526
+ const projectDirectory = channelConfig?.directory
504
527
 
505
- if (!channelConfig && !notifyOnly) {
506
- cliLogger.log('Channel not configured')
528
+ // Features that require a local project directory mapping
529
+ const needsProjectDirectory = Boolean(parsedSchedule || options.wait || options.cwd)
530
+ if (!channelConfig && needsProjectDirectory) {
507
531
  throw new Error(
508
- `Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`,
532
+ `Channel #${channelData.name} is not configured with a project directory. ` +
533
+ `${parsedSchedule ? '--send-at' : options.wait ? '--wait' : '--cwd'} requires a local project mapping. ` +
534
+ 'Run the bot first to sync channel data.',
509
535
  )
510
536
  }
511
537
 
512
- const projectDirectory = channelConfig?.directory
513
-
514
538
  // Validate --cwd is inside the project or an existing git worktree.
515
539
  let resolvedCwd: string | undefined
516
540
  if (options.cwd) {
517
- // projectDirectory is guaranteed here: --cwd is incompatible with --notify-only,
518
- // and non-notify sends already require channelConfig above.
541
+ // projectDirectory is guaranteed here: needsProjectDirectory check above
542
+ // already threw if channelConfig is missing when --cwd is used.
519
543
  const cwdResult = await resolveSessionWorkingDirectory({
520
544
  projectDirectory: projectDirectory!,
521
545
  candidatePath: options.cwd,
@@ -690,8 +714,8 @@ cli
690
714
  process.stdout.write(`${threadUrl}\n`)
691
715
 
692
716
  if (options.wait) {
693
- // projectDirectory is guaranteed here: --wait is incompatible with --notify-only,
694
- // and non-notify sends already require channelConfig above.
717
+ // projectDirectory is guaranteed here: needsProjectDirectory check above
718
+ // already threw if channelConfig is missing when --wait is used.
695
719
  const { waitAndOutputSession } = await import('../wait-session.js')
696
720
  await waitAndOutputSession({
697
721
  threadId: threadData.id,
@@ -188,7 +188,8 @@ cli
188
188
  'Read a session conversation as markdown (pipe to file to grep)',
189
189
  )
190
190
  .option('--project <path>', 'Project directory (defaults to cwd)')
191
- .action(async (sessionId: string, options: { project?: string }) => {
191
+ .option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
192
+ .action(async (sessionId: string, options: { project?: string; verbose?: boolean }) => {
192
193
  try {
193
194
  const projectDirectory = path.resolve(options.project || '.')
194
195
 
@@ -202,8 +203,9 @@ cli
202
203
  }
203
204
 
204
205
  // Try current project first (fast path)
206
+ const compactTools = !options.verbose
205
207
  const markdown = new ShareMarkdown(getClient())
206
- const result = await markdown.generate({ sessionID: sessionId })
208
+ const result = await markdown.generate({ sessionID: sessionId, compactTools })
207
209
  if (!(result instanceof Error)) {
208
210
  process.stdout.write(result)
209
211
  process.exit(0)
@@ -236,6 +238,7 @@ cli
236
238
  const otherMarkdown = new ShareMarkdown(otherClient())
237
239
  const otherResult = await otherMarkdown.generate({
238
240
  sessionID: sessionId,
241
+ compactTools,
239
242
  })
240
243
  if (!(otherResult instanceof Error)) {
241
244
  process.stdout.write(otherResult)
@@ -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,
@@ -45,6 +45,19 @@ function createMatchers(): DeterministicMatcher[] {
45
45
  },
46
46
  }
47
47
 
48
+ const toolCallMatcher: DeterministicMatcher = {
49
+ id: 'tool-call-reply',
50
+ priority: 90,
51
+ when: { latestUserTextIncludes: 'use a tool please' },
52
+ then: {
53
+ parts: [
54
+ { type: 'stream-start', warnings: [] },
55
+ { type: 'tool-call', toolCallId: 'tc1', toolName: 'bash', input: JSON.stringify({ command: 'echo hello world', description: 'Print greeting' }) },
56
+ { type: 'finish', finishReason: 'tool-calls', usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } },
57
+ ],
58
+ },
59
+ }
60
+
48
61
  const defaultMatcher: DeterministicMatcher = {
49
62
  id: 'default-reply',
50
63
  priority: 1,
@@ -59,13 +72,14 @@ function createMatchers(): DeterministicMatcher[] {
59
72
  },
60
73
  }
61
74
 
62
- return [helloMatcher, defaultMatcher]
75
+ return [helloMatcher, toolCallMatcher, defaultMatcher]
63
76
  }
64
77
 
65
78
  let client: OpencodeClient
66
79
  let directories: ReturnType<typeof createRunDirectories>
67
80
  let testStartTime: number
68
81
  let sessionID: string
82
+ let toolSessionID: string
69
83
 
70
84
  beforeAll(async () => {
71
85
  testStartTime = Date.now()
@@ -148,7 +162,39 @@ beforeAll(async () => {
148
162
  setTimeout(resolve, 200)
149
163
  })
150
164
  }
151
- }, 20_000)
165
+
166
+ // Create a second session that triggers a tool call (bash echo)
167
+ const toolCreateResult = await client.session.create({
168
+ directory: directories.projectDirectory,
169
+ title: 'Tool Call Session',
170
+ })
171
+ toolSessionID = toolCreateResult.data!.id
172
+
173
+ await client.session.promptAsync({
174
+ sessionID: toolSessionID,
175
+ directory: directories.projectDirectory,
176
+ parts: [{ type: 'text', text: 'use a tool please' }],
177
+ })
178
+
179
+ // Wait for tool execution to complete
180
+ const toolMaxWait = 15_000
181
+ const toolPollStart = Date.now()
182
+ while (Date.now() - toolPollStart < toolMaxWait) {
183
+ const msgs = await client.session.messages({
184
+ sessionID: toolSessionID,
185
+ directory: directories.projectDirectory,
186
+ })
187
+ const messages = msgs.data || []
188
+ const hasToolPart = messages.some((m) =>
189
+ m.parts.some((p) => p.type === 'tool' && p.state?.status === 'completed'),
190
+ )
191
+ if (hasToolPart) {
192
+ await new Promise((resolve) => { setTimeout(resolve, 500) })
193
+ break
194
+ }
195
+ await new Promise((resolve) => { setTimeout(resolve, 200) })
196
+ }
197
+ }, 30_000)
152
198
 
153
199
  afterAll(async () => {
154
200
  if (directories) {
@@ -313,3 +359,44 @@ test('generate markdown with lastAssistantOnly', async () => {
313
359
  // Should contain the assistant response
314
360
  expect(markdown).toContain('Hello! This is a deterministic markdown test response.')
315
361
  })
362
+
363
+ test('compact tools: tool calls show one-liner with line count', async () => {
364
+ const exporter = new ShareMarkdown(client)
365
+
366
+ const result = await exporter.generate({
367
+ sessionID: toolSessionID,
368
+ compactTools: true,
369
+ })
370
+
371
+ expect(errore.isOk(result)).toBe(true)
372
+ const md = errore.unwrap(result)
373
+
374
+ // Compact mode: exact one-liner format with params and line count
375
+ expect(md).toContain(
376
+ '> 🛠️ **bash** command=echo hello world, description=Print greeting',
377
+ )
378
+ expect(md).toMatch(/\(\d+ lines?\)/)
379
+ // Should NOT contain full output code blocks or input YAML
380
+ expect(md).not.toContain('**Output:**')
381
+ expect(md).not.toContain('**Input:**')
382
+ expect(md).not.toContain('```yaml')
383
+ })
384
+
385
+ test('verbose tools: tool calls show full input and output', async () => {
386
+ const exporter = new ShareMarkdown(client)
387
+
388
+ const result = await exporter.generate({
389
+ sessionID: toolSessionID,
390
+ compactTools: false,
391
+ })
392
+
393
+ expect(errore.isOk(result)).toBe(true)
394
+ const md = errore.unwrap(result)
395
+
396
+ // Verbose mode: full tool rendering with input YAML and output code block
397
+ expect(md).toContain('#### 🛠️ Tool: bash')
398
+ expect(md).toContain('**Input:**')
399
+ expect(md).toContain('```yaml')
400
+ expect(md).toContain('**Output:**')
401
+ expect(md).toContain('hello world')
402
+ })